diff --git a/Cargo.lock b/Cargo.lock index 920a735ab..f6bebf778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.24.0" +version = "0.24.1" dependencies = [ "arboard", "block2 0.6.2", @@ -6360,7 +6360,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.24.0" +version = "0.24.1" dependencies = [ "crc32fast", "flate2", @@ -6371,14 +6371,14 @@ dependencies = [ [[package]] name = "vectorcraft-apischema" -version = "0.24.0" +version = "0.24.1" dependencies = [ "serde_json", ] [[package]] name = "vectorcraft-brush" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "serde", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6406,7 +6406,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.24.0" +version = "0.24.1" dependencies = [ "log", "objc2-core-foundation", @@ -6429,7 +6429,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6441,7 +6441,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.24.0" +version = "0.24.1" dependencies = [ "chrono", "hayro-ccitt", @@ -6460,7 +6460,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.24.0" +version = "0.24.1" dependencies = [ "cfb", "chrono", @@ -6499,6 +6499,7 @@ dependencies = [ "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", + "same-file", "serde", "serde_json", "skrifa 0.47.0", @@ -6531,7 +6532,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-pdf", "encoding_rs", @@ -6559,7 +6560,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-vectorcraft-format", "flate2", @@ -6575,7 +6576,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6587,7 +6588,7 @@ dependencies = [ [[package]] name = "vectorcraft-js" -version = "0.24.0" +version = "0.24.1" dependencies = [ "chrono", "getrandom 0.4.3", @@ -6597,7 +6598,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "log", @@ -6616,7 +6617,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6630,7 +6631,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6642,7 +6643,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.24.0" +version = "0.24.1" dependencies = [ "aes", "chrono", @@ -6676,7 +6677,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-vectorcraft-format", "image", @@ -6695,7 +6696,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.24.0" +version = "0.24.1" dependencies = [ "flate2", "gif 0.14.2", @@ -6725,7 +6726,7 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.24.0" +version = "0.24.1" dependencies = [ "proptest", "unicode-properties", @@ -6733,7 +6734,7 @@ dependencies = [ [[package]] name = "vectorcraft-svg" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-docpath", "flate2", @@ -6760,7 +6761,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.24.0" +version = "0.24.1" dependencies = [ "fax", "image", @@ -6778,7 +6779,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.24.0" +version = "0.24.1" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6799,7 +6800,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.24.0" +version = "0.24.1" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6807,7 +6808,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6824,7 +6825,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6836,7 +6837,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.24.0" +version = "0.24.1" dependencies = [ "bytemuck", "craft-keymap", @@ -6874,7 +6875,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-web-api", "eframe", @@ -8006,7 +8007,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.24.0" +version = "0.24.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 606c34f8b..b733a0f3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.24.0" +version = "0.24.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 5bdd2bf63..0b1c56090 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -3,8 +3,6 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// Returns the path of `name` in `vectorcraft-cli-test-` in the temp folder. The first call removes /// that folder and creates it again empty. fn tmp(name: &str) -> std::path::PathBuf { @@ -27,7 +25,12 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap(); let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id()))); let run = |extra: &[&str]| { - Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap() + Command::new(crate::bin()) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .args(["run", "--commands", steps.to_str().unwrap()]) + .args(extra) + .output() + .unwrap() }; let out = run(&[]); assert!(!out.status.success()); @@ -38,7 +41,7 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id()))); let mcp_install = |extra: &[&str]| -> Value { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .env("VECTORCRAFT_CONFIG_DIR", &mcp_config) .args(["mcp", "--headless"]) .args(extra) @@ -77,22 +80,23 @@ fn plugin_grants_need_the_start_up_flag() { approval_param_needs_the_start_up_flag("grant", json!(["doc.read"])); approval_param_needs_the_start_up_flag("allowHeavy", json!(true)); - let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); let root = std::env::temp_dir(); for flag in ["--automation-read-root", "--automation-write-root"] { - let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); + let out = + Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); assert!(!out.status.success(), "{flag}"); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}"); } - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect")); } #[test] fn commands_prints_catalogue() { - let out = Command::new(BIN).arg("commands").output().unwrap(); + let out = Command::new(crate::bin()).arg("commands").output().unwrap(); assert!(out.status.success()); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); @@ -104,7 +108,7 @@ fn run_batch_exports() { let svg = tmp("batch.svg"); let png = tmp("batch.png"); let dc = tmp("batch.vectorcraft"); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#]) .args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#]) .args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##]) @@ -121,7 +125,7 @@ fn run_batch_exports() { assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400); // Re-open the native file and inspect. - let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap(); assert_eq!(last["result"]["artboards"][0]["width"], 200.0); @@ -137,7 +141,7 @@ fn scale_sizes_raster_exports_only() { let svg = svg.to_str().unwrap(); let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png")); let ok = |args: &[&str]| { - let out = Command::new(BIN).args(args).output().unwrap(); + let out = Command::new(crate::bin()).args(args).output().unwrap(); assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); }; ok(&["convert", svg, plain.to_str().unwrap()]); @@ -151,11 +155,11 @@ fn scale_sizes_raster_exports_only() { #[test] fn run_reports_errors() { - let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); assert!(!out.status.success()); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}"); - let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--params", "{}"]).output().unwrap(); assert!(!out.status.success()); } @@ -184,7 +188,7 @@ fn run_reads_steps_from_a_commands_file() { {"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}}, ]); std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap(); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()]) .args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()]) .output() @@ -204,7 +208,7 @@ fn run_reads_steps_from_a_commands_file() { #[test] fn run_reads_json_lines_from_standard_input() { let svg = tmp("stdin.svg"); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["run", "--commands", "-", "--export", svg.to_str().unwrap()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -227,7 +231,7 @@ fn run_reads_json_lines_from_standard_input() { #[test] fn run_refuses_malformed_commands_naming_the_step() { let fails = |args: &[&str]| { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); assert!(!out.status.success(), "{args:?}"); (String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len()) }; @@ -275,11 +279,11 @@ fn run_refuses_malformed_commands_naming_the_step() { /// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints. #[test] fn help_after_a_subcommand_prints_the_usage() { - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:")); for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] { for flag in ["--help", "-h"] { - let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args([sub, flag]).stdin(Stdio::null()).output().unwrap(); assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout)); } @@ -290,7 +294,7 @@ fn help_after_a_subcommand_prints_the_usage() { /// with them. #[test] fn help_later_in_the_line_prints_the_usage() { - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); let mut wrong = vec![]; for args in [ &["mcp", "--headless", "--help"][..], @@ -311,7 +315,7 @@ fn help_later_in_the_line_prints_the_usage() { &["perf", "--paths", "many", "-h"], &["convert", "--decimals", "many", "--help"], ] { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); if !out.status.success() || out.stdout != usage.stdout { wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout))); } @@ -322,17 +326,18 @@ fn help_later_in_the_line_prints_the_usage() { /// Further along the line, `-h` is the value of the option before it. #[test] fn help_as_an_option_value_goes_to_the_option() { - let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "-h"]).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}"); - let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["convert", "in.svg", "--range", "-h"]).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}"); } #[test] fn mcp_headless_over_stdio() { - let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut child = + Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); { let mut stdin = child.stdin.take().unwrap(); for m in [ @@ -359,13 +364,13 @@ fn mcp_headless_over_stdio() { /// 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 out = Command::new(crate::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 out = Command::new(crate::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}"); } @@ -402,7 +407,7 @@ fn mcp_automation_roots_take_several_folders() { } let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned(); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))]) .args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")]) .stdin(Stdio::piped()) @@ -457,7 +462,7 @@ fn automation_roots(base: &std::path::Path) { } let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); let fails = |args: &[&str], why: &str| { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains(why), "{args:?}: {err}"); }; @@ -469,7 +474,7 @@ fn automation_roots(base: &std::path::Path) { "a running app enforces its own roots: start it with `vectorcraft --control ", ); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -530,7 +535,7 @@ fn automation_roots(base: &std::path::Path) { fn mcp_protocol_over_stdio() { use std::io::{BufRead, BufReader}; let mut child = - Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); let mut notes = vec![]; @@ -711,7 +716,7 @@ fn run_command(id: u64, command: &str, params: Value) -> Value { #[test] fn closed_stdout_ends_quietly() { use std::io::Read; - let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child = Command::new(crate::bin()).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); let mut first = [0u8; 16]; child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap(); drop(child.stdout.take()); @@ -728,13 +733,13 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let _ = std::fs::remove_file(&svg); let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#; let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()]; - let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child = Command::new(crate::bin()).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); drop(child.stdout.take()); let out = child.wait_with_output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status); assert!(svg.is_file(), "the export after the closed reader still ran"); - let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } @@ -749,7 +754,7 @@ fn config_dir(name: &str) -> std::path::PathBuf { /// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; /// the result of each step. fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { - let mut cmd = Command::new(BIN); + let mut cmd = Command::new(crate::bin()); cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); for (id, params) in steps { cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); @@ -820,7 +825,7 @@ fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { #[test] fn runs_without_preferences_remember_nothing() { let config = config_dir("noprefs"); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) .env("VECTORCRAFT_CONFIG_DIR", &config) .env("VECTORCRAFT_NO_PREFS", "1") diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index c60357b5f..71044be4a 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -4,8 +4,6 @@ use std::process::Command; use serde_json::Value; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// Returns the path of `name` in `vectorcraft-cli-fileio-` in the temp folder. The first call /// removes that folder and creates it again empty. fn tmp(name: &str) -> String { @@ -20,7 +18,7 @@ fn tmp(name: &str) -> String { } fn ok(args: &[&str]) -> String { - let out = Command::new(BIN).args(args).output().unwrap(); + let out = Command::new(crate::bin()).args(args).output().unwrap(); assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8(out.stdout).unwrap() } @@ -130,7 +128,7 @@ fn raster_exports_use_document_resolution_unless_overridden() { fn raster_export_rejects_invalid_ppi() { for ppi in ["0", "-150", "NaN", "inf", "high"] { for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] { - let out = Command::new(BIN).args(&args).output().unwrap(); + let out = Command::new(crate::bin()).args(&args).output().unwrap(); assert!(!out.status.success(), "{args:?}"); assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}"); } @@ -228,7 +226,7 @@ fn convert_writes_legacy_ai_with_its_options() { assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}"); assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0")); // A value the engine doesn't take is an error. - let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap(); + let bad = Command::new(crate::bin()).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap(); assert!(!bad.status.success()); assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr)); } @@ -253,7 +251,7 @@ fn convert_and_run_write_ait_templates() { assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}"); } // A template holds every artboard: one can't be picked. - let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap(); + let one = Command::new(crate::bin()).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap(); assert!(!one.status.success()); assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr)); } diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs index 07e5a3a70..d898dda20 100644 --- a/apps/vectorcraft-cli/tests/it/main.rs +++ b/apps/vectorcraft-cli/tests/it/main.rs @@ -6,3 +6,11 @@ mod cli; mod fileio; mod mcp_e2e; mod script; + +/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner. +fn bin() -> std::path::PathBuf { + std::env::var_os("NEXTEST_BIN_EXE_vectorcraft_cli") + .or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft-cli")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft-cli").into()) +} diff --git a/apps/vectorcraft-cli/tests/it/mcp_e2e.rs b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs index 5a353070a..4931602ec 100644 --- a/apps/vectorcraft-cli/tests/it/mcp_e2e.rs +++ b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs @@ -6,8 +6,6 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use serde_json::{Value, json}; use vectorcraft_testkit::raster::Image; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - struct Mcp { child: Child, stdin: Option, @@ -17,7 +15,7 @@ struct Mcp { impl Mcp { fn spawn() -> Self { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--headless"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/apps/vectorcraft-cli/tests/it/script.rs b/apps/vectorcraft-cli/tests/it/script.rs index d68716f7b..3652e585d 100644 --- a/apps/vectorcraft-cli/tests/it/script.rs +++ b/apps/vectorcraft-cli/tests/it/script.rs @@ -2,11 +2,9 @@ use std::process::Command; use serde_json::{Value, json}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// `run` with `script.run` after `file.new`, with `params`. fn run_script(params: &Value) -> std::process::Output { - Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap() + Command::new(crate::bin()).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap() } /// `run` runs a script headless: the reply holds the report, with what the script logged and the diff --git a/apps/vectorcraft/tests/it/automation_args.rs b/apps/vectorcraft/tests/it/automation_args.rs index 93b33fa16..4a1e6f524 100644 --- a/apps/vectorcraft/tests/it/automation_args.rs +++ b/apps/vectorcraft/tests/it/automation_args.rs @@ -1,8 +1,6 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); - /// Invalid roots must exit before desktop initialization on every platform, including a CI /// machine with no display. A regression must fail rather than hang with a window open. fn rejects(args: &[&str], message: &str) { @@ -10,7 +8,7 @@ fn rejects(args: &[&str], message: &str) { } fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(args) .env_remove("VECTORCRAFT_CONTROL_PORT") .env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT") @@ -62,7 +60,7 @@ fn missing_empty_or_option_operands_fail_before_startup() { #[test] fn repeated_roots_are_accepted_for_both_spellings() { for flag in ["--automation-read-root", "--automation-write-root"] { - let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap(); + let out = Command::new(crate::bin()).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap(); assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); assert!(out.stderr.is_empty()); @@ -73,7 +71,8 @@ fn repeated_roots_are_accepted_for_both_spellings() { #[test] fn explicit_dash_folder_does_not_consume_version() { - let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap(); + let out = + Command::new(crate::bin()).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); assert!(out.stderr.is_empty()); @@ -95,7 +94,7 @@ fn control_without_a_valid_port_fails_before_startup() { #[test] fn version_wins_over_a_bad_control_port_variable() { - let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap(); + let out = Command::new(crate::bin()).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); } diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index ece66b98a..d82475b9f 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -6,12 +6,10 @@ use std::process::{Command, Output, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); - /// Runs the desktop binary without a display and with a control port it cannot parse, so a /// regression exits with that error instead of opening a window. fn run(args: &[&str], env: &[(&str, &str)]) -> Output { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(args) .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") diff --git a/apps/vectorcraft/tests/it/main.rs b/apps/vectorcraft/tests/it/main.rs index d9dcf39cb..658f4d49f 100644 --- a/apps/vectorcraft/tests/it/main.rs +++ b/apps/vectorcraft/tests/it/main.rs @@ -4,3 +4,11 @@ mod automation_args; mod help; + +/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner. +fn bin() -> std::path::PathBuf { + std::env::var_os("NEXTEST_BIN_EXE_vectorcraft") + .or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft").into()) +} diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e2d27650b..12d233ba3 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -46,7 +46,7 @@ impl Document { return (0, 0); } let own = size_of::() + ALLOC + ARC; - let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] }; + let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own], appearances: Default::default() }; w.list(&self.layers, &other.layers); for i in 0..self.symbols.len().max(other.symbols.len()) { match (self.symbols.get(i), other.symbols.get(i)) { @@ -134,6 +134,9 @@ struct Walk<'a> { seen: [Option>; 2], /// For each side, what it keeps that the other doesn't. bytes: [usize; 2], + /// For each side, the appearances counted so far (by address): objects that share one (a + /// graphic style applied to them all) count it once. + appearances: [HashSet; 2], } impl Walk<'_> { @@ -174,8 +177,9 @@ impl Walk<'_> { self.alone(1, b); return; } - self.bytes[0] += node_bytes(a, Some(b)); - self.bytes[1] += node_bytes(b, Some(a)); + let [ca, cb] = &mut self.appearances; + self.bytes[0] += node_bytes(a, Some(b), ca); + self.bytes[1] += node_bytes(b, Some(a), cb); match (a.children(), b.children()) { (Some(x), Some(y)) => self.list(x, y), (x, y) => { @@ -198,8 +202,8 @@ impl Walk<'_> { if self.other_holds(side, n) { return; } - if let Some(b) = self.bytes.get_mut(side) { - *b += node_bytes(n, None); + if let (Some(b), Some(counted)) = (self.bytes.get_mut(side), self.appearances.get_mut(side)) { + *b += node_bytes(n, None, counted); } for c in n.children().into_iter().flatten() { self.alone(side, c); @@ -242,14 +246,15 @@ impl Walk<'_> { } /// One node's own heap: the node, and what it owns besides its children and mask art (counted -/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it. -fn node_bytes(n: &Node, other: Option<&Node>) -> usize { +/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it, or +/// another node of the same side shares it and was counted already (its address is in `counted`). +fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> usize { fn boxed(b: &Option>) -> usize { b.as_ref().map_or(0, |_| ALLOC + size_of::()) } let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); - if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) && counted.insert(&*n.appearance as *const Appearance as usize) { b += ALLOC + ARC + appearance_heap_bytes(&n.appearance); } if let Some(e) = n.extras_box() { @@ -447,6 +452,29 @@ mod tests { assert!(added > kept + 3 * 300, "{added} for three new rectangles"); } + /// Objects given one shared appearance (a graphic style applied to them all) count it once. + #[test] + fn a_shared_appearance_counts_once() { + let n = 100; + let before = doc_with_rects(n); + let ids: Vec<_> = before.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + let ap = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 2.0); + let restyled = |each: bool| { + let mut d = before.clone(); + let one = Shared::new(ap.clone()); + for id in &ids { + d.node_mut(*id).unwrap().appearance = if each { Shared::new(ap.clone()) } else { one.clone() }; + } + d + }; + let (shared, copies) = (restyled(false), restyled(true)); + let (kept, added) = before.retained_bytes_each(&shared); + assert_eq!(shared.retained_bytes_each(&before), (added, kept)); + let (kept_copies, added_copies) = before.retained_bytes_each(&copies); + assert_eq!(kept, kept_copies, "the old version keeps the same either way"); + assert_eq!(added_copies - added, (n - 1) * (ALLOC + ARC + appearance_heap_bytes(&ap))); + } + #[test] fn a_resource_counts_only_when_changed() { let before = doc_with_rects(1); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index e9bbaf4bb..5c18f2d5f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -176,29 +176,29 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}), ), g( EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( INFLATE, "Inflate…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( CLASSIC_EXTRUDE, diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 43b339ccf..4795e8848 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -112,7 +112,9 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let b = union_bounds(&shapes).ok_or(EMPTY)?; let options = shared_options(&shapes, revolve_options(p))?; let mut copies = 0; - return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies)); + shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); + return check_shadow(p, casting.map(|s| &s.path), b, options); } let (path, _) = object_profile(n, "Revolve")?; if !has_two_anchors(&path) { @@ -121,7 +123,18 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) + check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?; + check_shadow(p, [&path], b, revolve_options(p)) +} + +/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast +/// from, about the axis of `b`, are past the limits together. +fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: Rect, options: Revolve) -> Result<(), String> { + if crate::solids::shadow::options(p).is_none() { + return Ok(()); + } + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + paths.into_iter().try_for_each(|path| caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options).map(|_| ())) } /// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of @@ -144,14 +157,16 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; - let options = match at.and_then(|at| effects.get(at)) { - _ if i == target => revolve_options(p), - Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)), + let params = match at.and_then(|at| effects.get(at)) { + _ if i == target => p.clone(), + Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params), _ => continue, }; + let options = revolve_options(¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + check_shadow(¶ms, [&path], b, options)?; } Ok(()) } @@ -189,9 +204,10 @@ fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } -/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. +/// Check Revolve's parameter values `p` alone: its lights, shadow, `edge` and `cap`. pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; + crate::solids::shadow::validate(p)?; if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } @@ -205,6 +221,43 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } +/// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of +/// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with +/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when +/// the surface it is cast from is past the limits. +fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result, String> { + let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) }; + let (_, opacity, blend) = paint_of(item); + if opacity <= 0.0 { + return Ok(None); + } + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?; + Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend })) +} + +/// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one, +/// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque, +/// in its paint's blending mode, and its opacity mask and blending mode apply to that part +/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the +/// limits. +fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result>, String> { + let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) }; + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + let mut cast = vec![]; + for s in shapes { + let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue }; + // A member whose surface is fully transparent casts nothing. + let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; + if ink.opacity <= 0.0 { + continue; + } + let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?; + cast.push((s.scopes.as_slice(), ink, faces)); + } + Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) +} + /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts /// the bytes of the paint copies of every surface of the object. #[allow(clippy::too_many_arguments)] @@ -445,7 +498,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?)); + let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; + if let Some(b) = path.bounds() + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + { + children.push(Arc::new(shadow)); + } + children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -458,6 +517,8 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining.retain(|e| !is_geometry(&e.id)); + // Every item's shadow goes behind every item's art. + let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { let fx = item.effects().get(index)?; @@ -465,11 +526,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + if let Some(b) = p.bounds() + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + { + shadow.appearance.effects = art.appearance.effects.clone(); + shadows.push(Arc::new(shadow)); + } children.push(Arc::new(art)); } else { children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } + children.splice(0..0, shadows); } let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -704,6 +772,9 @@ fn group_art(n: &Node, expand: bool) -> Option { scoped(n.id, runs.collect(), 0) } }; + // One shadow for every member's surface, behind them all. + let mut children = children; + children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); let mut art = n.clone(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index be6c68ba7..31d42d642 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -20,6 +20,8 @@ pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, clas mod source; pub(crate) use source::{resolve, resolve_for}; +pub(crate) mod shadow; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -192,6 +194,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { classic::validate(p)?; } crate::lighting::validate(p)?; + shadow::validate(p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -214,17 +217,31 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// build from the art they show ([`resolve`]). pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_for(doc, n, id, p, None) +} + +/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item +/// `item`, whose shadow is cast by that item's solid alone. +fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; - return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); + shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + return cast_shadow(id, p, &shapes).map(|_| ()); } let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; - facets(id, &path, rule, b, p).map(|_| ()) + facets(id, &path, rule, b, p)?; + // With its shadow on, the shadow of the object's solids (or of the item's) must fit too. + let items = match item.and_then(|i| n.appearance.items.get(i)) { + Some(item) => vec![item.clone()], + None => solid_paints(n), + }; + let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + cast_shadow(id, p, &shapes).map(|_| ()) } /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from @@ -297,7 +314,7 @@ fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { } /// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal? -fn has_masks(shapes: &[Profile]) -> bool { +pub(crate) fn has_masks(shapes: &[Profile]) -> bool { shapes.iter().any(|s| s.scopes.iter().any(composites)) } @@ -420,7 +437,8 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), - _ => validate_three_d_in(doc, n, id, p), + Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), + None => validate_three_d_in(doc, n, id, p), } } @@ -551,6 +569,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile } } +/// A 3D effect's shadow and its surfaces ([`art_for`]). +type ShadowAndSurfaces = (Vec>, Vec>); + /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, @@ -559,8 +580,87 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds -/// ([`masked`]). -fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it +/// off), to be drawn behind every surface of the object, and the surfaces. +fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + let art = surfaces_for(n, fx, shapes)?; + // A shadow past the limits draws the source path, as a solid past them does; commands report + // the error ([`validate_solid_in`]). + let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { + Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), + None => vec![], + }; + Some((shadow, art)) +} + +/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on +/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the +/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow +/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past +/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error. +/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to +/// the part of the shadow they cast too ([`shadow::scoped_art`]). +fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result>, String> { + if id == CLASSIC_EXTRUDE { + return Ok(None); + } + let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; + let view = view_options(p); + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + // The faces each solid of visible art added, with its member's scopes and its ink (opacity + // and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing + // and does not place the plane. + let mut cast: Vec = vec![]; + let ink = |item: &AppearanceItem, member: f32| { + let (_, opacity, blend) = paint_of(item, member); + shadow::Ink { opacity, blend } + }; + if id == ROTATE { + for s in shapes { + for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) { + let (area, rule) = region(&s.path, s.rule, item); + let faces = caster.plane(&area, rule, b, view)?; + cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces)); + } + } + } else { + let (parts, bounds) = solid_parts(shapes, b); + let inks: Vec = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); + // Inflate's eye is placed for every shape together, as the solids are. + let added: Vec, String>> = if id == INFLATE { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) + } else { + let mut added = vec![]; + for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) { + added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); + } + added + }; + for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) { + if ink.opacity <= 0.0 { + continue; + } + // A stroke whose own solid is past the limits is left out; past the shadow's limit for + // all the solids together, the shadow is refused. + let faces = match faces { + Ok(faces) => faces, + Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue, + Err(e) => return Err(e), + }; + cast.push((*scopes, ink, faces)); + } + } + Ok(Some((caster, settings, cast))) +} + +/// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with +/// its member's scopes and ink ([`cast_shadow`]). +type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); + +/// [`art_for`] without the shadow: the solids' surfaces. +fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE; @@ -676,7 +776,8 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); + let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; + children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -689,18 +790,24 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + // Every item's shadow goes behind every item's art. + let mut shadows = vec![]; for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { - Arc::make_mut(&mut art).appearance.effects = - item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); - children.push(art); - } + let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); + let with_later = |mut art: Arc| { + Arc::make_mut(&mut art).appearance.effects = later.clone(); + art + }; + shadows.extend(shadow.into_iter().map(with_later)); + children.extend(surfaces.into_iter().map(with_later)); } else { children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } + children.splice(0..0, shadows); } let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -716,7 +823,8 @@ fn group_solid_art(n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); - let children = art_for(n, fx, &shapes)?; + let (shadow, surfaces) = art_for(n, fx, &shapes)?; + let children = shadow.into_iter().chain(surfaces).collect(); let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); let mut art = n.clone(); @@ -906,3 +1014,6 @@ mod tests_mesh; #[cfg(test)] mod tests_sources; + +#[cfg(test)] +mod tests_shadow; diff --git a/crates/effects/src/solids/shadow.rs b/crates/effects/src/solids/shadow.rs new file mode 100644 index 000000000..3906ac2d3 --- /dev/null +++ b/crates/effects/src/solids/shadow.rs @@ -0,0 +1,101 @@ +//! The shadow of the 3D and Materials effects (Extrude & Bevel, Inflate, Rotate and Revolve): the +//! `shadow` switch with `shadowPosition`, `shadowDistance` and `shadowBounds`, and the shape it +//! draws behind the solids. +use std::ops::Range; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId}; +use vectorcraft_three_d::{Shadow, ShadowCaster, ShadowPlane, ShadowPosition}; + +use super::Scope; +use crate::util::{flag, num, text}; + +/// The shadow's paint: black at this opacity. VectorCraft's own values. +const SHADOW_OPACITY: f32 = 0.5; + +/// The shadow `p` asks for, or `None` with the switch off or when the first light lights nothing +/// (Shading is off or its intensity is 0), so that no shadow is built. +pub(crate) fn options(p: &Value) -> Option { + let intensity = crate::lighting::lights(p).and_then(|l| l.iter().next().map(|l| l.intensity)).unwrap_or_else(|| num(p, "lightIntensity", 80.0)); + let lit = flag(p, "shade", true) && intensity > 0.0; + (lit && flag(p, "shadow", false)).then(|| Shadow { + position: ShadowPosition::parse(text(p, "shadowPosition", "behind")).unwrap_or_default(), + distance: num(p, "shadowDistance", 0.0) / 100.0, + bounds: num(p, "shadowBounds", 100.0) / 100.0, + }) +} + +/// Check the shadow settings in `p`: an error for a value out of its range or of the wrong kind. +pub(crate) fn validate(p: &Value) -> Result<(), String> { + if p.get("shadow").is_some_and(|v| !v.is_boolean()) { + return Err("shadow must be true or false".into()); + } + if p.get("shadowPosition").is_some_and(|v| v.as_str().and_then(ShadowPosition::parse).is_none()) { + return Err("shadowPosition must be behind or below".into()); + } + for (key, min, max) in [("shadowDistance", 0.0, 100.0), ("shadowBounds", 10.0, 400.0)] { + if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(|n| n.is_finite() && (min..=max).contains(&n))) { + return Err(format!("{key} must be a number from {min} to {max}")); + } + } + Ok(()) +} + +/// The opacity and blending mode of the fill or stroke (and member) that casts part of a shadow. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Ink { + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + +/// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled +/// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule; +/// `None` when it casts nothing. +pub(crate) fn art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, ink: Ink) -> Option { + let all = 0..caster.len(); + let all = std::slice::from_ref(&all); + piece(id, caster, &caster.shadow_plane(shadow, all)?, all, ink) +} + +/// [`art`] of the faces in the ranges `faces` of `caster` only, on `plane`. +fn piece(id: NodeId, caster: &ShadowCaster, plane: &ShadowPlane, faces: &[Range], ink: Ink) -> Option { + let path = caster.path_on(plane, faces)?; + let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); + fill.opacity = SHADOW_OPACITY * ink.opacity.clamp(0.0, 1.0); + fill.blend = ink.blend; + Some(Node::path(id, path, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() })) +} + +/// Whether `a` and `b` are the same objects' scopes (each member holds its own copies). +fn same_scopes(a: &[Scope], b: &[Scope]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.key == b.key) +} + +/// The faces one solid added to a caster, with the scopes of its member and the ink of its art. +pub(crate) type Cast<'a> = (&'a [Scope], Ink, Range); + +/// The faces of the solids that cast one piece of a shadow, with their scopes and ink. +type Run<'a> = (&'a [Scope], Ink, Vec>); + +/// The shadow `cast` casts on one plane: the faces of `caster` that the solids of visible art +/// added, each range with the scopes of the member that cast it and the ink of the art, in order. +/// Each run of solids in the same ink casts a piece in that ink ([`art`]); with `masks` (a member's +/// opacity mask or blending mode applies to its solids) the solids of a run also share their scopes, +/// and each piece is drawn in their opacity masks and blending modes ([`super::masked`]). +pub(crate) fn scoped_art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, cast: Vec>, masks: bool) -> Vec> { + let placing: Vec> = cast.iter().map(|(.., faces)| faces.clone()).collect(); + let Some(plane) = caster.shadow_plane(shadow, &placing) else { return vec![] }; + // Consecutive solids in the same ink (and with masks, the same scopes) share a piece; the pieces + // keep the solids' order. + let mut runs: Vec = vec![]; + for (scopes, ink, faces) in cast { + match runs.last_mut() { + Some(run) if run.1 == ink && (!masks || same_scopes(run.0, scopes)) => run.2.push(faces), + _ => runs.push((scopes, ink, vec![faces])), + } + } + let pieces = runs.into_iter().filter_map(|(scopes, ink, faces)| Some((scopes, Arc::new(piece(id, caster, &plane, &faces, ink)?)))); + if masks { super::masked(id, pieces.collect()) } else { pieces.map(|(_, piece)| piece).collect() } +} diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs new file mode 100644 index 000000000..4400555d2 --- /dev/null +++ b/crates/effects/src/solids/tests_shadow.rs @@ -0,0 +1,225 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_geom::{Affine, BezPath}; + +use super::*; + +const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 }; + +/// The 3D and Materials effects that cast shadows. +const CASTING: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, crate::REVOLVE]; + +fn square_node(id: u64, dx: f64) -> Node { + let mut b = BezPath::new(); + b.move_to((100.0 + dx, 100.0)); + b.line_to((200.0 + dx, 100.0)); + b.line_to((200.0 + dx, 200.0)); + b.line_to((100.0 + dx, 200.0)); + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0)) +} + +fn art(n: &Node, id: &str) -> Option { + if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) } +} + +/// The opacity of shadow `n`: a path filled black and nothing else; `None` for any other node. +fn shadow_opacity(n: &Node) -> Option { + let [AppearanceItem::Fill(fill)] = n.appearance.items.as_slice() else { return None }; + (matches!(n.kind, NodeKind::Path { .. }) && fill.paint.color() == Some(Color::BLACK)).then_some(fill.opacity) +} + +/// Whether `n` is the shadow of opaque art: a path filled black at 50 %, nothing else. +fn is_shadow(n: &Node) -> bool { + shadow_opacity(n) == Some(0.5) +} + +fn shadows(n: &Node) -> usize { + let mut count = 0; + n.walk(&mut |c| count += usize::from(is_shadow(c))); + count +} + +#[test] +fn each_3d_effect_draws_its_shadow_behind_its_solid_when_shadows_are_on() { + for id in CASTING { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, &json!({})).unwrap()); + let plain = art(&n, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&plain), 0, "{id}: off by default"); + n.appearance.effects[0].params = json!({"shadow": true, "shadowDistance": 50}); + let lit = art(&n, id).unwrap_or_else(|| panic!("{id}")); + let children = lit.children().unwrap(); + assert!(is_shadow(&children[0]), "{id}: the shadow is drawn first"); + assert_eq!(shadows(&lit), 1, "{id}"); + // The default light is up and to the left: the shadow on a plane away from the object + // reaches past the solid down and right. + let shadow = children[0].visual_bounds().unwrap(); + let solid = children[1..].iter().filter_map(|c| c.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!(shadow.x1 > solid.x1 && shadow.y1 > solid.y1, "{id}: {shadow:?} {solid:?}"); + // The art's bounds take the shadow in. + assert!(lit.visual_bounds().unwrap().contains_rect(shadow), "{id}"); + } +} + +#[test] +fn the_shadow_moves_with_its_settings() { + for id in CASTING { + let bounds = |params: Value| { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + let lit = art(&n, id).unwrap(); + let shadow = &lit.children().unwrap()[0]; + assert!(is_shadow(shadow), "{id} {params}"); + shadow.visual_bounds().unwrap() + }; + let near = bounds(json!({"shadow": true})); + let far = bounds(json!({"shadow": true, "shadowDistance": 100})); + assert!(far.x1 > near.x1 + 1.0, "{id}: {near:?} {far:?}"); + // With the light straight in front, the outline falls about the plane's center; at 10 % + // the plane cuts it to a square 0.4 times the object's size across. + let tight = bounds(json!({"shadow": true, "shadowBounds": 10, "lightHeight": 90})); + let wide = bounds(json!({"shadow": true, "shadowBounds": 400, "lightHeight": 90})); + assert!(wide.width() > tight.width() * 2.0, "{id}: {tight:?} {wide:?}"); + let below = bounds(json!({"shadow": true, "shadowPosition": "below", "rotationX": -30})); + assert_ne!(below, near, "{id}"); + } +} + +#[test] +fn shadow_settings_out_of_range_or_of_the_wrong_kind_are_refused() { + for id in CASTING { + for (params, error) in [ + (json!({"shadow": "yes"}), "shadow must be true or false"), + (json!({"shadowPosition": "under"}), "shadowPosition must be behind or below"), + (json!({"shadowPosition": 1}), "shadowPosition must be behind or below"), + (json!({"shadowDistance": 101}), "shadowDistance must be a number from 0 to 100"), + (json!({"shadowDistance": -1}), "shadowDistance must be a number from 0 to 100"), + (json!({"shadowBounds": 5}), "shadowBounds must be a number from 10 to 400"), + (json!({"shadowBounds": "wide"}), "shadowBounds must be a number from 10 to 400"), + ] { + let err = validate_three_d_params(id, ¶ms).unwrap_err(); + assert!(err.contains(error), "{id} {params}: {err}"); + } + let ok = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 100, "shadowBounds": 10}); + assert!(validate_three_d_params(id, &ok).is_ok(), "{id}"); + } +} + +#[test] +fn a_group_casts_one_shadow_for_all_its_members() { + for id in CASTING { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 200.0))]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&lit), 1, "{id}"); + let shadow = lit.children().unwrap()[0].clone(); + assert!(is_shadow(&shadow), "{id}"); + // With the light straight in front, the shadow behind spans both members. + let b = shadow.visual_bounds().unwrap(); + assert!(b.x0 < 200.0 && b.x1 > 300.0, "{id}: {b:?}"); + } +} + +/// A member's opacity mask applies to the part of the shadow it casts, as to its solid: that part +/// is drawn in the mask, the other member's outside it, both on one plane. +#[test] +fn a_masked_member_casts_its_part_of_the_shadow_in_its_mask() { + for id in CASTING { + let mut masked = square_node(2, 200.0); + let mut cover = square_node(3, 200.0); + cover.appearance.items = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0).items; + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(cover, true))); + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(masked)]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&lit), 2, "{id}: one piece for each member"); + let mut in_mask = 0; + lit.walk(&mut |c| { + if c.extras().mask.is_some() { + c.walk(&mut |s| in_mask += usize::from(is_shadow(s))); + } + }); + assert_eq!(in_mask, 1, "{id}"); + } +} + +#[test] +fn transparent_art_and_a_light_that_lights_nothing_cast_no_shadow() { + for id in CASTING { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "shade": false})).unwrap()); + assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: Shading off"); + n.appearance.effects[0].params = json!({"shadow": true, "lightIntensity": 0}); + assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: no intensity"); + // A member at 0 % opacity casts nothing; the other member still casts its shadow. + let mut clear = square_node(2, 200.0); + clear.opacity = 0.0; + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(clear)]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + let shadow = lit.children().unwrap()[0].clone(); + assert!(is_shadow(&shadow), "{id}"); + let b = shadow.visual_bounds().unwrap(); + assert!(b.x1 < 300.0, "{id}: only the visible member casts: {b:?}"); + // Art at 50 % casts a shadow half as dark. + let mut half = square_node(1, 0.0); + for item in &mut half.appearance.items { + if let AppearanceItem::Fill(fill) = item { + fill.opacity = 0.5; + } + } + half.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let lit = art(&half, id).unwrap(); + assert_eq!(shadow_opacity(&lit.children().unwrap()[0]), Some(0.25), "{id}"); + // Art in a blending mode casts its shadow in that mode. + for item in &mut half.appearance.items { + if let AppearanceItem::Fill(fill) = item { + fill.blend = BlendMode::Screen; + } + } + let lit = art(&half, id).unwrap(); + let AppearanceItem::Fill(fill) = &lit.children().unwrap()[0].appearance.items[0] else { panic!("{id}: a fill") }; + assert_eq!(fill.blend, BlendMode::Screen, "{id}"); + } +} + +/// With a 3D effect on each of two fills, both fills' shadows are drawn behind both fills' solids. +#[test] +fn the_shadows_of_every_fill_go_behind_every_fill_s_solid() { + for id in CASTING { + let mut n = square_node(1, 0.0); + let fill = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap().clone(); + n.appearance.items = vec![fill.clone(), fill]; + for item in &mut n.appearance.items { + item.effects_mut().push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + } + let lit = art(&n, id).unwrap_or_else(|| panic!("{id}")); + let children = lit.children().unwrap(); + assert!(children.len() >= 4, "{id}"); + assert!(is_shadow(&children[0]) && is_shadow(&children[1]), "{id}: both shadows first"); + assert!(children[2..].iter().all(|c| !is_shadow(c)), "{id}"); + } +} + +#[test] +fn expand_appearance_keeps_the_shadow_as_a_path() { + for id in CASTING { + let mut d = Document::new(400.0, 400.0); + let mut n = square_node(1, 0.0); + n.id = d.alloc_id(); + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; + let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&expanded), 1, "{id}"); + let mut effects = 0; + expanded.walk(&mut |c| effects += c.appearance.effects.len()); + assert_eq!(effects, 0, "{id}"); + // It moves with the object, as the solid does. + let before = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap(); + n.transform(Affine::translate((40.0, 0.0)), false); + let after = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap(); + assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}"); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 836d4b744..0fccbcdec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -67,6 +67,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# Whether a recovery area's lock file is still the one that was opened (`FolderStore::still_named`). +# Already in Cargo.lock (walkdir), so this adds no new crate. +[target.'cfg(windows)'.dependencies] +same-file = "1.0.6" + # The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e4c8d7945..558407356 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -881,3 +881,56 @@ fn move_takes_several_effects_that_land_together_in_order() { assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err()); assert_eq!(node(&s, id).appearance, before); } + +/// A 3D effect's shadow is set with `effect.setParams` in one undo step, saved in the native file, +/// drawn in exports and kept as a path by Expand Appearance; settings out of range are refused +/// without an undo step. +#[test] +fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for (params, why) in [ + (json!({"shadowBounds": 401}), "shadowBounds must be a number from 10 to 400"), + (json!({"shadowPosition": "left"}), "shadowPosition must be behind or below"), + ] { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap_err().to_string(); + assert!(e.contains(why), "{params}: {e}"); + assert_eq!(s.doc().unwrap().doc, before); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let params = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 20, "shadowBounds": 150}); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let art = effects::solid_art(&node(&s, id)).unwrap(); + let shadow = art.children().unwrap()[0].clone(); + assert!(matches!(shadow.kind, NodeKind::Path { .. })); + // Saved and opened again, the effect keeps its shadow settings and draws the same shadow. + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + let fx = &restored.node(id).unwrap().appearance.effects[0]; + for (key, value) in params.as_object().unwrap() { + assert_eq!(fx.params.get(key), Some(value), "{key}"); + } + assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art); + // SVG export draws it: one more shape than without the shadow. + let shapes = |s: &mut Session| { + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + vectorcraft_testkit::svg_shapes(&svg) + }; + let with = shapes(&mut s); + s.execute("edit.undo", &json!({})).unwrap(); + let without = shapes(&mut s); + assert_eq!(with, without + 1); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + let mut shadows = 0; + s.doc().unwrap().doc.walk(|n| { + if n.path_data().is_some() && n.path_data() == shadow.path_data() && n.appearance.items == shadow.appearance.items { + shadows += 1; + } + }); + assert_eq!(shadows, 1); +} diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index ac3332699..a64c74e03 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -1,8 +1,8 @@ //! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As //! → EPS Options): the options parsed from the params, transparency flattened with a flattener //! preset, one file of the art's bounds or one per artboard (with a central file of the whole -//! document when there are several and art shows), the preview and thumbnail rendered, -//! and the native document embedded for reopening. +//! document when there are several and art shows), the preview rendered and the thumbnail made +//! from it, and the native document embedded for reopening. use std::borrow::Cow; @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Document; use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; +use vectorcraft_render::Rendered; use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded}; use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; @@ -231,14 +232,38 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi } } -/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA, -/// transparent or on white. -fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { +/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side. +fn fit(rect: Rect, max: f64) -> f64 { let long = rect.width().max(rect.height()); - let scale = if long > max { max / long } else { 1.0 }; + if long > max { max / long } else { 1.0 } +} + +/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white. +fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { + let scale = fit(rect, max); vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?; - let img = r.render_region(doc, rect, scale, !transparent); - Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() }) + Ok(r.render_region(doc, rect, scale, !transparent)) +} + +/// `img` as the writer takes it: straight RGBA. +fn straight(img: &Rendered) -> Raster { + Raster { width: img.width, height: img.height, rgba: img.to_straight() } +} + +/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a +/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white. +fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result { + let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?; + let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels; + // Premultiplied over white: each channel gains the white the pixel's alpha leaves showing. + for px in rgba.as_chunks_mut::<4>().0 { + let clear = 255 - px[3]; + for c in &mut px[..3] { + *c = c.saturating_add(clear); + } + px[3] = 255; + } + Ok(Raster { width, height, rgba }) } /// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of @@ -318,11 +343,18 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); - let preview = match set.eps.preview { + let shown = match set.eps.preview { Preview::None => None, kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; - let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; + // The thumbnail is made from the preview; only a file without one renders it. + let thumbnail = match (set.thumbnails, &shown) { + (false, _) => None, + (true, Some(img)) => Some(thumbnail_of(img, rect)?), + (true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)), + }; + let preview = shown.as_ref().map(straight); + drop(shown); let o = EpsOptions { region, origin, diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 8e2c1d429..db667e6ef 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -218,6 +218,106 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() { assert!(l3.contains("/FlateDecode filter >> image")); } +/// The PNG thumbnail of an EPS file, decoded. +fn thumbnail_png(bytes: &[u8]) -> image::RgbaImage { + let png = vectorcraft_eps::thumbnail(bytes).expect("a thumbnail"); + image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap().to_rgba8() +} + +/// The TIFF preview of an EPS file, decoded. +fn preview_tiff(bytes: &[u8]) -> image::RgbaImage { + let tiff = vectorcraft_eps::sections(bytes).unwrap().1.expect("a preview"); + image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8() +} + +/// The size of a thumbnail render of the rect an EPS file's preview shows (its bounding box). +fn thumbnail_size(bytes: &[u8]) -> (u32, u32) { + let b: Vec = dsc(&postscript(bytes), "BoundingBox").unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let rect = vectorcraft_geom::Rect::new(b[0], b[1], b[2], b[3]); + let long = rect.width().max(rect.height()); + vectorcraft_render::raster_size(rect, if long > 128.0 { 128.0 / long } else { 1.0 }).unwrap() +} + +/// The thumbnail is the color preview made smaller, the area average of its pixels at the size a +/// 128 px render has, not a second render of the document; it shows the same picture as one. +#[test] +fn the_thumbnail_is_made_from_the_preview() { + let mut s = session(1000.0, 700.0, 1); + s.execute("paint.setFill", &json!({"color": "#2060c0"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 37.3, "y": 41.9, "width": 811.7, "height": 563.1})).unwrap(); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "tiffColor", "transparentPreview": false})).unwrap(); + let bytes = b64(&r); + let preview = preview_tiff(&bytes); + assert_eq!(preview.dimensions(), (1000, 700)); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (128, 90)); + // An opaque preview's straight pixels are its premultiplied ones. + let shown = vectorcraft_render::Rendered { width: 1000, height: 700, pixels: preview.into_raw() }; + assert!(thumb.as_raw() == &shown.area_resized(128, 90).unwrap().pixels, "the thumbnail isn't the preview made smaller"); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 700.0), 0.128, true); + let mut diffs: Vec = thumb.as_raw().iter().zip(&fresh.pixels).map(|(a, b)| a.abs_diff(*b)).collect(); + diffs.sort_unstable(); + let mean = diffs.iter().map(|d| f64::from(*d)).sum::() / diffs.len() as f64; + let p95 = diffs[diffs.len() * 95 / 100]; + assert!(mean <= 8.0 && p95 <= 32, "mean {mean}, 95th percentile {p95}"); +} + +/// A thumbnail is opaque color on white whatever the preview: a transparent color preview's, and a +/// black-and-white preview's, which keeps the art's colors. +#[test] +fn thumbnails_of_transparent_and_black_and_white_previews_are_opaque_color() { + let mut s = session(400.0, 200.0, 1); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap(); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), 0.32, true); + let red: [u8; 4] = fresh.pixels[(32 * 128 + 10) * 4..][..4].try_into().unwrap(); + for preview in ["tiffColor", "tiffBw"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let thumb = thumbnail_png(&b64(&r)); + assert_eq!(thumb.dimensions(), (128, 64), "{preview}"); + assert_eq!(thumb.get_pixel(10, 32).0, red, "{preview}"); + assert_eq!(thumb.get_pixel(120, 32).0, [255, 255, 255, 255], "{preview}"); + assert!(thumb.pixels().all(|p| p[3] == 255), "{preview}"); + } +} + +/// A page that fits in a thumbnail gets its preview's own pixels, unresampled. +#[test] +fn a_small_page_s_thumbnail_is_its_preview() { + let mut s = session(100.0, 60.0, 1); + s.execute("shape.ellipse", &json!({"x": 10.5, "y": 7.25, "width": 70, "height": 41})).unwrap(); + let bytes = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "transparentPreview": false})).unwrap()); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (100, 60)); + assert!(thumb == preview_tiff(&bytes), "the thumbnail isn't the preview"); +} + +/// Every file has a thumbnail at the size a thumbnail render has, with any page shape, on Save +/// too and without a preview; with thumbnails off there is none. +#[test] +fn every_file_s_thumbnail_has_the_thumbnail_size() { + let mut s = session(333.3, 77.7, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("shape.rectangle", &json!({"x": 3.7, "y": 5.1, "width": 300, "height": 61.3})).unwrap(); + s.execute("shape.ellipse", &json!({"x": second + 1.0, "y": 2.0, "width": 20, "height": 70})).unwrap(); + for preview in ["tiffColor", "tiffBw", "none"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 3); + for f in files { + let bytes = b64(f); + assert_eq!(thumbnail_png(&bytes).dimensions(), thumbnail_size(&bytes), "{preview} {}", f["name"]); + } + } + assert_eq!(thumbnail_size(&b64(&s.execute("document.exportEps", &json!({"range": "1"})).unwrap())), (128, 30)); + let saved = b64(&s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap()); + assert_eq!(thumbnail_png(&saved).dimensions(), thumbnail_size(&saved)); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "thumbnails": false})).unwrap(); + assert!(r["files"].as_array().unwrap().iter().all(|f| vectorcraft_eps::thumbnail(&b64(f)).is_none())); +} + /// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings` /// holds the note raster exports give, once, without naming either image's key. #[test] diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 4cf070c6a..b75066ec6 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -111,8 +111,13 @@ pub trait RecoveryStore: Send + Sync { fn announced(&self, _area: &str) -> Option { None } - /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder). + /// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds + /// the area until this returns. fn remove_area(&self, _area: &str) {} + /// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the + /// caller let the area go. On some file systems a file that is open is removed only when it is + /// closed, so `remove_area` leaves the folder there while the area is held. + fn remove_empty_area(&self, _area: &str) {} /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone). fn now(&self) -> Option { vectorcraft_doc::metadata::now_unix() @@ -295,6 +300,73 @@ impl FolderStore { .filter(|n| Self::plain(n)) .collect()) } + + /// Lock area folder `dir`, which was just made. Another app tidies an empty area away + /// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder + /// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer + /// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one. + pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result { + if let Err(e) = Self::own_area(dir) { + let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound); + return if gone { Ok(Lock::Busy) } else { Err(e) }; + } + let path = dir.join(LOCK_FILE); + match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) { + Ok(file) => Self::take(file, &path), + Err(e) => Self::open_failed(e, &path), + } + } + + /// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the + /// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some + /// file systems keep a removed file that another app still has open until that app closes it, + /// and refuse to open it meanwhile. A lock file that can't be made is an error. + pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result { + match e.kind() { + std::io::ErrorKind::NotFound => Ok(Lock::Busy), + std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), + _ => Err(format!("{}: {e}", path.display())), + } + } + + /// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still + /// names that file. + pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result { + match file.try_lock() { + Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))), + Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), + // The lock file was removed meanwhile, and locking it failed instead. + Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), + Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + } + } + + /// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on + /// Windows they must be the same file (volume serial number and file index); elsewhere `path` + /// must exist. When either file can't be read, it does not. + fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + match (file.metadata(), std::fs::symlink_metadata(path)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(windows)] + { + let opened = file.try_clone().and_then(same_file::Handle::from_file); + match (opened, same_file::Handle::from_path(path)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + std::fs::symlink_metadata(path).is_ok() + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -342,29 +414,23 @@ impl RecoveryStore for FolderStore { fn lock(&self, area: &str) -> std::result::Result { let dir = self.area(area)?; Self::create_area(&dir)?; - Self::own_area(&dir)?; - let path = dir.join(LOCK_FILE); - let file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .map_err(|e| format!("{}: {e}", path.display()))?; - match file.try_lock() { - Ok(()) => Ok(Lock::Held(Box::new(file))), - Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), - } + Self::lock_area(&dir) } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty - // (copies came back meanwhile) stays. + // (copies came back meanwhile) stays. The caller still holds the lock here. if let Ok(dir) = self.area(area) { let _ = std::fs::remove_file(dir.join(LOCK_FILE)); let _ = std::fs::remove_dir(&dir); } } + fn remove_empty_area(&self, area: &str) { + // Best effort, as above. An app that made the folder again meanwhile and has not locked it + // yet finds it gone and takes another area (`lock_area`); one that has keeps it. + if let Ok(dir) = self.area(area) { + let _ = std::fs::remove_dir(&dir); + } + } } /// The area this session keeps its copies in, while it holds it. @@ -527,8 +593,10 @@ pub fn release(s: &mut Session) { for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); } - drop(hold); + // Held until it is gone, as in `tidy`. store.remove_area(&area); + drop(hold); + store.remove_empty_area(&area); } /// Let go of this session's area but leave its copies, as a crash would, so the next app started @@ -967,7 +1035,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } -/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). +/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The +/// area is removed while it is still held, so an app taking a new area of that name meanwhile +/// finds it held or gone and takes the next name. fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { let names = store.list().unwrap_or_default(); let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect(); @@ -977,8 +1047,9 @@ fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { for n in mine { let _ = store.remove(n); } - drop(hold); store.remove_area(area); + drop(hold); + store.remove_empty_area(area); } /// The copies of documents open now in this session. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d31031d5d..bca9ed6ea 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -13,11 +13,12 @@ //! to loses its characters' own fill (stroke) when the style has fills (strokes), so the style's //! paint shows instead of the character colour under it. +use std::borrow::Cow; use std::collections::HashMap; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared}; use vectorcraft_geom::Rect; use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; @@ -260,9 +261,14 @@ fn override_chars(n: &mut Node, ap: &Appearance) { } /// Give `n` style `g`: its appearance (a group or layer too: its own, above its contents; type its -/// characters' colour too with `chars`, Override Character Color) and transparency. -fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { - n.appearance = g.appearance_on(n).into_owned().into(); +/// characters' colour too with `chars`, Override Character Color) and transparency. Objects that +/// take `g`'s appearance as it is share one copy of it, made in `shared` the first time; an object +/// whose placed gradients land on its own bounds gets its own. +fn style_node(n: &mut Node, g: &GraphicStyle, shared: &mut Option>, chars: bool) { + n.appearance = match g.appearance_on(n) { + Cow::Borrowed(ap) => shared.get_or_insert_with(|| Shared::new(ap.clone())).clone(), + Cow::Owned(ap) => ap.into(), + }; if chars { override_chars(n, &g.appearance); } @@ -270,9 +276,9 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { } /// Give `id` style `g` ([`style_node`]) and link it. -fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { +fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, shared: &mut Option>, chars: bool) { if let Some(n) = d.node_mut(id) { - style_node(n, g, chars); + style_node(n, g, shared, chars); n.extras_mut().graphic_style = Some(g.id); super::object::snap_if_aligned(n); } @@ -313,7 +319,7 @@ impl Session { /// Color as set): the Graphic Styles panel's previews. pub fn styled(&self, n: &Node, g: &GraphicStyle) -> Node { let mut n = n.clone(); - style_node(&mut n, g, self.prefs.override_char_color); + style_node(&mut n, g, &mut None, self.prefs.override_char_color); n } } @@ -354,8 +360,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); + let mut shared = None; for id in &ids { - apply_style(d, *id, &g, chars); + apply_style(d, *id, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } @@ -474,8 +481,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); + let mut shared = None; for nid in &follow { - apply_style(d, *nid, &g, chars); + apply_style(d, *nid, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); diff --git a/crates/engine/src/tests/gradpanel.rs b/crates/engine/src/tests/gradpanel.rs index 85fd4cf02..680abfa9e 100644 --- a/crates/engine/src/tests/gradpanel.rs +++ b/crates/engine/src/tests/gradpanel.rs @@ -132,6 +132,44 @@ fn graphic_styles_keep_gradients_relative_to_the_bounds() { assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored"); } +/// Applying a style with a placed gradient to objects of different sizes gives each its own +/// appearance with the gradient on its own bounds, through undo and redo; a style from an older +/// file applies the same stored gradient to each, as one shared appearance. +#[test] +fn a_style_places_its_gradient_on_each_object() { + let (mut s, src, dst) = source_and_target(); + let other = rect(&mut s, 100.0, 300.0, 50.0, 200.0); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Glow", "ids": [dst.0, other.0]})).unwrap(); + let placed = |s: &Session| { + let (g, h) = (fill_geom(s, dst).unwrap(), fill_geom(s, other).unwrap()); + close(g.start, Point::new(540.0, 60.0)) + && close(g.end, Point::new(660.0, 60.0)) + && close(h.start, Point::new(110.0, 400.0)) + && close(h.end, Point::new(140.0, 400.0)) + }; + let shared = |s: &Session| { + let d = &s.doc().unwrap().doc; + vectorcraft_doc::Shared::ptr_eq(&d.node(dst).unwrap().appearance, &d.node(other).unwrap().appearance) + }; + assert!(placed(&s) && !shared(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(placed(&s) && !shared(&s)); + + let glow = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap(); + let legacy = vectorcraft_doc::GraphicStyle { name: "Old".into(), id: 0, unit_box: false, ..glow }; + s.edit("Add", |d, _| { + d.graphic_styles.push(legacy); + Ok(()) + }) + .unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Old", "ids": [dst.0, other.0]})).unwrap(); + assert!(shared(&s)); + assert!(close(fill_geom(&s, other).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored"); +} + #[test] fn eyedropper_options_choose_what_copy_from_takes() { let mut s = session(); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 9fe2d846c..4994a9072 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -483,6 +483,205 @@ fn the_folder_store_reads_no_linked_entries() { let _ = std::fs::remove_dir_all(&base); } +/// A store in memory that notes each removal of an area (its lock file and folder, then its empty +/// folder) and whether the area was still held then. +#[derive(Default)] +struct Tidying { + inner: MemoryStore, + removals: std::sync::Mutex>, +} + +impl Tidying { + fn note(&self, what: &'static str, area: &str) { + let held = matches!(self.inner.lock(area), Ok(cmd::recovery::Lock::Busy)); + self.removals.lock().unwrap().push((what, held)); + } +} + +impl RecoveryStore for Tidying { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn remove_area(&self, area: &str) { + self.note("area", area); + } + fn remove_empty_area(&self, area: &str) { + self.note("empty folder", area); + } + fn now(&self) -> Option { + self.inner.now() + } +} + +/// An app tidying an area away (a crashed app's, or its own when it quits) removes it while it +/// still holds it, so another app taking a new area of that name can't lock it in between. Once it +/// let the area go, it removes the folder again if that is empty. +#[test] +fn areas_are_removed_while_still_held() { + let store = Arc::new(Tidying::default()); + let app = || { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s + }; + let mut a = app(); + a.execute("file.recovery.save", &json!({})).unwrap(); + drop(a); + let mut b = app(); + assert_eq!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().len(), 1); + b.execute("file.recovery.save", &json!({})).unwrap(); + b.execute("file.close", &json!({})).unwrap(); + cmd::recovery::forget_all(&mut b); + let tidy = [("area", true), ("empty folder", false)]; + assert_eq!(*store.removals.lock().unwrap(), [tidy, tidy].concat(), "discard's tidy, then quitting"); +} + +/// Another app tidied an empty area away between this app making it and locking it: the area is +/// busy, not held and not an error. +#[test] +fn an_area_tidied_away_before_it_is_locked_is_busy() { + use cmd::recovery::Lock; + let base = std::env::temp_dir().join(format!("vc-recovery-tidied-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let store = FolderStore::new(&base); + drop(store.lock("5-1").unwrap()); + let area = base.join("5-1"); + let path = area.join(".lock"); + let open = |create: bool| std::fs::OpenOptions::new().read(true).write(true).create(create).truncate(false).open(&path).unwrap(); + // The lock file was opened, then removed before it was locked. + let opened = open(false); + std::fs::remove_file(&path).unwrap(); + assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy))); + // The folder went before it was checked or its lock file opened. + std::fs::remove_dir(&area).unwrap(); + assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy))); + // A lock file made again under the same name is not the one opened, also where it keeps the + // removed one's creation time (NTFS does within 15 seconds). + #[cfg(any(unix, windows))] + { + std::fs::create_dir_all(&area).unwrap(); + let opened = open(true); + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"").unwrap(); + assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy))); + assert!(matches!(FolderStore::take(open(false), &path), Ok(Lock::Held(_)))); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// Opening an area's lock file failed. When the folder went after it was checked, or the lock file +/// is there but can't be opened (a removed lock file that another app still has open, on file +/// systems that keep it until it is closed), the area is busy. A lock file that can't be made is an +/// error. +#[test] +fn a_lock_file_that_is_gone_or_cannot_be_opened_is_busy() { + use cmd::recovery::Lock; + use std::io::ErrorKind::{NotFound, PermissionDenied}; + let base = std::env::temp_dir().join(format!("vc-recovery-unopened-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let path = base.join(".lock"); + let failed = |kind: std::io::ErrorKind| FolderStore::open_failed(kind.into(), &path); + assert!(matches!(failed(NotFound), Ok(Lock::Busy))); + assert!(failed(PermissionDenied).is_err()); + std::fs::write(&path, b"").unwrap(); + assert!(matches!(failed(PermissionDenied), Ok(Lock::Busy))); + #[cfg(unix)] + if !rustix::process::geteuid().is_root() { + use std::os::unix::fs::PermissionsExt as _; + let mode = |p: &std::path::Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap(); + // A lock file that is there but can't be opened. + let area = base.join("5-1"); + std::fs::create_dir(&area).unwrap(); + std::fs::write(area.join(".lock"), b"").unwrap(); + mode(&area.join(".lock"), 0o400); + assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy))); + // A folder in which no lock file can be made. + let area = base.join("5-2"); + std::fs::create_dir(&area).unwrap(); + mode(&area, 0o500); + let r = FolderStore::lock_area(&area); + mode(&area, 0o700); + assert!(r.is_err()); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// A folder store in which another app tidies the first area away as soon as it is made. +struct TidiedFirst { + inner: FolderStore, + dir: std::path::PathBuf, + first: std::sync::atomic::AtomicBool, +} + +impl RecoveryStore for TidiedFirst { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn areas(&self) -> std::result::Result, String> { + self.inner.areas() + } + fn lock(&self, area: &str) -> std::result::Result { + if !self.first.swap(false, std::sync::atomic::Ordering::SeqCst) { + return self.inner.lock(area); + } + let dir = self.dir.join(area); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + FolderStore::lock_area(&dir) + } + fn remove_area(&self, area: &str) { + self.inner.remove_area(area); + } +} + +#[test] +fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() { + let dir = std::env::temp_dir().join(format!("vc-recovery-next-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = Arc::new(TidiedFirst { inner: FolderStore::new(&dir), dir: dir.clone(), first: true.into() }); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let area = s.recovery.own_area().unwrap().to_string(); + assert!(area.ends_with("-2"), "{area}"); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); diff --git a/crates/engine/src/tests/styles.rs b/crates/engine/src/tests/styles.rs index 3ffb2ed61..6f77b5897 100644 --- a/crates/engine/src/tests/styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{GraphicStyle, Knockout, Node}; +use vectorcraft_doc::{GraphicStyle, Knockout, Node, Shared}; use crate::*; @@ -202,6 +202,77 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err()); } +/// Applying a style to several objects gives them one shared copy of its appearance; editing one +/// object gives that one its own, and the saved file is the same as with a copy for each. +#[test] +fn applying_a_style_shares_one_appearance() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "appearance.clear", json!({})); + run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); + run(&mut s, "paint.setStroke", json!({"color": "#0000ff"})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + run(&mut s, "graphicStyle.new", json!({"name": "Rough"})); + let rough = style(&s, "Rough"); + let ids = [rect(&mut s, 100.0), rect(&mut s, 200.0), rect(&mut s, 300.0)]; + let [b, c, d] = ids; + run(&mut s, "graphicStyle.apply", json!({"name": "Rough", "ids": [b.0, c.0, d.0]})); + let shared = |s: &Session, x: NodeId, y: NodeId| Shared::ptr_eq(&node(s, x).appearance, &node(s, y).appearance); + assert!(shared(&s, b, c) && shared(&s, c, d), "one appearance for every object"); + for id in ids { + assert_eq!(node(&s, id).appearance, rough.appearance); + assert_eq!(node(&s, id).extras().graphic_style, Some(rough.id)); + } + // The saved file is the same as one where each object has its own copy. + let doc = s.doc().unwrap().doc.clone(); + let mut copies = (*doc).clone(); + for id in ids { + let n = copies.node_mut(id).unwrap(); + n.appearance = Shared::new((*n.appearance).clone()); + } + assert!(!Shared::ptr_eq(&copies.node(b).unwrap().appearance, &copies.node(c).unwrap().appearance)); + for pretty in [false, true] { + assert_eq!(vectorcraft_format::save(&doc, pretty), vectorcraft_format::save(&copies, pretty)); + } + + // Editing one object leaves the others and the style as they were. + set_fill(&mut s, b, "#ff0000"); + assert!(!shared(&s, b, c) && shared(&s, c, d)); + assert_eq!(fill(&s, b), Paint::solid(Color::from_hex("#ff0000").unwrap())); + assert_eq!(node(&s, c).appearance, rough.appearance); + assert_eq!(style(&s, "Rough"), rough); + assert_eq!(linked(&mut s, "Rough"), vec![a.0, c.0, d.0]); + + // Undo and redo restore the values, still shared. + run(&mut s, "edit.undo", json!({})); + assert!(shared(&s, b, c) && node(&s, b).appearance == rough.appearance); + run(&mut s, "edit.undo", json!({})); + for id in ids { + assert_ne!(node(&s, id).appearance, rough.appearance); + assert_eq!(node(&s, id).extras().graphic_style, None); + } + run(&mut s, "edit.redo", json!({})); + assert!(shared(&s, b, c) && shared(&s, c, d) && node(&s, d).appearance == rough.appearance); +} + +/// Redefine Graphic Style gives the objects still linked one shared copy of the new appearance. +#[test] +fn redefine_shares_one_appearance() { + let mut s = session(); + let (a, b, c) = linked_three(&mut s); + let d = rect(&mut s, 300.0); + run(&mut s, "graphicStyle.apply", json!({"name": "S"})); + set_fill(&mut s, d, "#00ff00"); + set_fill(&mut s, a, "#0000ff"); + run(&mut s, "graphicStyle.redefine", json!({"name": "S", "id": a.0})); + let (nb, nc) = (node(&s, b), node(&s, c)); + assert!(Shared::ptr_eq(&nb.appearance, &nc.appearance)); + assert_eq!(nb.appearance, style(&s, "S").appearance); + // The edited object keeps its own look. + assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap())); + assert!(!Shared::ptr_eq(&node(&s, d).appearance, &nb.appearance)); +} + #[test] fn deleting_a_style_unlinks_and_renaming_keeps_links() { let mut s = session(); diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index bf6d28cdd..734731158 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1395,7 +1395,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.insertStop", "Insert Stop", "", - "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + "ui.action.insertMenuItem", + "Insert Menu Item", + "", + "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), ( "ui.action.continue", diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 8a152e11f..8ca670eb3 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -264,6 +264,25 @@ pub(crate) fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { out } +/// For each of `to` pixels spread over `from` (both above 0), the `from` pixels under it and the +/// share of it each covers (summing to 1). +fn area_spans(from: usize, to: usize) -> Vec> { + let step = from as f64 / to as f64; + (0..to) + .map(|i| { + let (start, end) = ((i * from) as f64 / to as f64, ((i + 1) * from) as f64 / to as f64); + let first = start.floor() as usize; + let last = (end.ceil() as usize).max(first + 1).min(from); + (first..last) + .filter_map(|j| { + let cover = end.min((j + 1) as f64) - start.max(j as f64); + (cover > 0.0).then_some((j, cover / step)) + }) + .collect() + }) + .collect() +} + /// A rendered image (premultiplied RGBA8, row-major). pub struct Rendered { pub width: u32, @@ -285,6 +304,49 @@ impl Rendered { } out } + /// This image at `width`×`height` pixels: each new pixel is the average of the area of this one + /// under it, the pixels at its edges weighted by how much of them it covers. Colors are averaged + /// premultiplied, so transparent pixels add no color. At the same size it is a copy. An error + /// when a side is 0 or the pixel buffer doesn't match the image size. + pub fn area_resized(&self, width: u32, height: u32) -> Result { + let (sw, sh) = (self.width as usize, self.height as usize); + if sw.checked_mul(sh).and_then(|n| n.checked_mul(4)) != Some(self.pixels.len()) { + return Err("the pixel buffer doesn't match the image size".into()); + } + if sw == 0 || sh == 0 || width == 0 || height == 0 { + return Err(format!("a {sw} × {sh} image can't be resized to {width} × {height} pixels")); + } + if (width, height) == (self.width, self.height) { + return Ok(Rendered { width, height, pixels: self.pixels.clone() }); + } + let (dw, dh) = (width as usize, height as usize); + let len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| format!("a {width} × {height} image is too large"))?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(len).map_err(|e| format!("a {width} × {height} image: {e}"))?; + let (cols, rows) = (area_spans(sw, dw), area_spans(sh, dh)); + let src: Vec<&[[u8; 4]]> = self.pixels.as_chunks::<4>().0.chunks_exact(sw).collect(); + let mut acc = vec![[0f64; 4]; dw]; + for row in &rows { + acc.fill([0.0; 4]); + for &(y, wy) in row { + let Some(line) = src.get(y) else { continue }; + for (a, col) in acc.iter_mut().zip(&cols) { + for &(x, wx) in col { + if let Some(px) = line.get(x) { + let w = wy * wx; + for (s, v) in a.iter_mut().zip(px) { + *s += w * f64::from(*v); + } + } + } + } + } + for a in &acc { + pixels.extend(a.map(|v| v.round().clamp(0.0, 255.0) as u8)); + } + } + Ok(Rendered { width, height, pixels }) + } /// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use /// [`encode`]). pub fn to_png(&self) -> Result, String> { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d5c129e5c..83f613107 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::shapes; mod adjust; mod alternate; +mod arearesize; mod bitmapglyphs; mod blend; mod changed; diff --git a/crates/render/src/tests/arearesize.rs b/crates/render/src/tests/arearesize.rs new file mode 100644 index 000000000..a53c05dc5 --- /dev/null +++ b/crates/render/src/tests/arearesize.rs @@ -0,0 +1,66 @@ +//! `Rendered::area_resized`: area averages of premultiplied pixels at any size, edge pixels by +//! their share, a copy at the same size, and errors instead of malformed images. + +use crate::*; + +fn image(width: u32, height: u32, pixels: &[[u8; 4]]) -> Rendered { + Rendered { width, height, pixels: pixels.concat() } +} + +#[test] +fn whole_blocks_average_into_one_pixel() { + let a = [200, 100, 0, 255]; + let b = [0, 100, 200, 255]; + let img = image(4, 2, &[a, b, a, a, a, b, a, b]); + let out = img.area_resized(2, 1).unwrap(); + assert_eq!((out.width, out.height), (2, 1)); + assert_eq!(out.pixels, [100, 100, 100, 255, 150, 100, 50, 255]); +} + +/// Three pixels into two: each new pixel takes one whole old pixel and half of the middle one. +#[test] +fn edge_pixels_count_by_the_share_they_cover() { + let img = image(3, 1, &[[0, 0, 0, 255], [255, 255, 255, 255], [90, 90, 90, 255]]); + let out = img.area_resized(2, 1).unwrap(); + // (0 + 255 / 2) / 1.5 = 85, (255 / 2 + 90) / 1.5 = 145. + assert_eq!(out.pixels, [85, 85, 85, 255, 145, 145, 145, 255]); +} + +/// A transparent pixel carries no color into its opaque neighbor: the average is half red, half +/// clear, still pure red once un-premultiplied. +#[test] +fn transparent_pixels_add_no_color() { + let img = image(2, 1, &[[0, 0, 0, 0], [255, 0, 0, 255]]); + let out = img.area_resized(1, 1).unwrap(); + assert_eq!(out.pixels, [128, 0, 0, 128]); + assert_eq!(out.to_straight(), [255, 0, 0, 128]); +} + +#[test] +fn the_same_size_is_a_copy() { + let px: Vec<[u8; 4]> = (0..12u8).map(|i| [i * 20, 255 - i * 20, i, 255]).collect(); + let img = image(4, 3, &px); + let out = img.area_resized(4, 3).unwrap(); + assert_eq!(out.pixels, img.pixels); +} + +/// A solid image stays exactly its color at any smaller size, with no rounding drift at +/// fractional spans. +#[test] +fn a_solid_color_stays_exact() { + let px = vec![[12, 34, 56, 255]; 1000 * 7]; + let out = image(1000, 7, &px).area_resized(128, 3).unwrap(); + assert_eq!((out.width, out.height), (128, 3)); + assert!(out.pixels.as_chunks::<4>().0.iter().all(|p| *p == [12, 34, 56, 255])); +} + +#[test] +fn bad_sizes_are_errors() { + let img = image(2, 2, &[[0, 0, 0, 255]; 4]); + assert!(img.area_resized(0, 1).is_err()); + assert!(img.area_resized(1, 0).is_err()); + let short = Rendered { width: 2, height: 2, pixels: vec![0; 12] }; + assert!(short.area_resized(1, 1).err().is_some_and(|e| e.contains("doesn't match"))); + let empty = Rendered { width: 0, height: 5, pixels: Vec::new() }; + assert!(empty.area_resized(1, 1).is_err()); +} diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index dbfa37431..bac0edbe9 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -116,7 +116,9 @@ fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: b /// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`; /// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets -/// an opening bracket there flush with the line's start); `cands` are the break opportunities +/// an opening bracket there flush with the line's start); `end_credit(t)` is the width a line whose +/// last character is glyph `t - 1` gives back (Mojikumi sets a closing mark there half width), at +/// most that glyph's advance, and nothing at a hyphenation point; `cands` are the break opportunities /// in increasing order (the paragraph end is added); `flex` holds how far each glyph may stretch /// and shrink in a justified line. /// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance @@ -125,6 +127,7 @@ pub(crate) fn compose( g: &[SGlyph], width: &dyn Fn(usize) -> f64, start_credit: &dyn Fn(usize) -> f64, + end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint], flex: &[Flex], p: &Params, @@ -153,6 +156,11 @@ pub(crate) fn compose( } b }; + let line_end_credit = |t: usize| { + let credit = end_credit(t); + let most = t.checked_sub(1).and_then(|k| g.get(k)).map_or(0.0, |gl| gl.adv.max(0.0)); + if credit.is_finite() { credit.clamp(0.0, most) } else { 0.0 } + }; let mut all: Vec = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect(); all.push(Breakpoint { end: n, hyphen: 0.0 }); let (adj_demerits, hyphen_penalty) = @@ -170,7 +178,10 @@ pub(crate) fn compose( let Some(a) = nodes.get(ai) else { continue }; let t = trim(bp.end, a.pos); let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 }; - let natural = w[t] - w[a.pos] - credit + bp.hyphen; + // A line that ends with a hyphen ends with it, not with its last character. At most the + // last glyph's advance, so a longer line from the same node is never narrower. + let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) }; + let natural = w[t] - w[a.pos] - credit - end + bp.hyphen; let lw = width(a.line); // Adjustment ratio, badness and whether the line ends the active node's reach. let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a753dba77..a259ce088 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -253,29 +253,52 @@ pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, out } -/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces -/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. -/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. -pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { +/// Width of the word starting at glyph `i`: its advances up to the first place a line may break +/// (spaces left out, the hyphen of a soft hyphen in), the least room a line needs not to break +/// inside it. As in [`break_line`], kinsoku keeps a character that may not end a line with the one +/// after it, a No Break run (its spaces inside it included) is one word up to a forced line break, +/// and a cluster is never split. A soft hyphen just before a forced line break adds no hyphen. A list marker at `i` (glyphs of length 0) counts whole, with the word after it. A word +/// that ends where a line may end is measured as the line is set: without the spaces at its end, +/// and with its last glyph as narrow as `line_end` sets it; one that ends at a soft hyphen or past +/// the measured glyphs is not. +pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kinsoku) -> f64 { /// Glyphs measured at most: a word wrapped over many lines is measured again from each line's /// start, and one this long is broken inside whatever cell it meets. const MAX_GLYPHS: usize = 1024; let rest = g.get(i..).unwrap_or_default(); let marker = rest.iter().take_while(|gl| gl.len == 0).count(); let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); - for gl in rest.iter().skip(marker).take(MAX_GLYPHS) { - if gl.is_space() { - break; + let start = i + marker; + let mut end = start; + // The spaces at the end of what is measured so far (inside a No Break run), and the end of its + // last glyph that is not a space. + let (mut trailing, mut ink) = (0.0, start); + let set = |w: f64, trailing: f64, ink: usize| w - trailing - line_end_credit(g, line_end, ink); + while let Some(gl) = g.get(end).filter(|_| end - start < MAX_GLYPHS) { + // A forced line break ends the line, inside a No Break run too, as in [`break_line`]. + if gl.ch == '\u{2028}' { + return set(w, trailing, ink); } - if gl.is_soft_hyphen() { + if gl.is_space() && (end == start || !splits_no_break(g, end)) { + return set(w, trailing, ink); + } + let forced_next = g.get(end + 1).is_some_and(|n| n.ch == '\u{2028}'); + if gl.is_soft_hyphen() && !forced_next && kinsoku_allows(g, end, kinsoku) && !splits_no_break(g, end + 1) { return w + hyphen_glyph(gl).adv; } w += gl.adv; - if gl.break_after() { - break; + end += 1; + if gl.is_space() { + trailing += gl.adv; + } else { + (trailing, ink) = (0.0, end); + } + let cluster_ends = g.get(end).is_none_or(|next| next.byte != gl.byte); + if gl.break_after() && cluster_ends && kinsoku_allows(g, end - 1, kinsoku) && !splits_no_break(g, end) { + return set(w, trailing, ink); } } - w + if end >= g.len() { set(w, trailing, ink) } else { w } } /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. @@ -287,6 +310,8 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// keeps the line from breaking before the character that overflows, `kinsoku_type` decides /// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical /// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. +/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster +/// that fits only that narrower stays on the line when the line may end after it (empty: none). #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -301,6 +326,7 @@ pub(super) fn break_line( kinsoku_type: KinsokuShoriType, hanging: bool, vertical: bool, + line_end: &[f64], ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -342,7 +368,11 @@ pub(super) fn break_line( if gl.ch == '\u{2028}' { return (cluster_end, false); } - if j > first && !gl.is_space() && x + cluster_width > width + EPS { + // Measured as the line is set when it ends with this cluster: only the cluster's last glyph + // is narrowed, and only where the line may end. + let end_credit = + Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0); + if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; @@ -351,8 +381,8 @@ pub(super) fn break_line( } return (end, false); } - if let Some(end) = - push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical) + let pushed_out = last_break.map(|b| b.0); + if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, pushed_out, kinsoku, kinsoku_type, hanging, vertical, line_end) { return (end, false); } @@ -495,6 +525,30 @@ pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f (left, right, left_glyphs, right_glyphs) } +/// How much narrower a line whose last character is glyph `end - 1` is set: `line_end` of that +/// glyph ([`break_line`]), at most its advance, and 0 when it is not finite. +fn line_end_credit(g: &[SGlyph], line_end: &[f64], end: usize) -> f64 { + let Some(k) = end.checked_sub(1) else { return 0.0 }; + match (line_end.get(k), g.get(k)) { + (Some(&credit), Some(gl)) if credit.is_finite() && credit > 0.0 => credit.min(gl.adv.max(0.0)), + _ => 0.0, + } +} + +/// May [`break_line`] end a line after glyph `end - 1` (and the spaces after it)? At the paragraph's +/// end or a forced line break, and where it records a break without a hyphen. +fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool { + let mut e = end; + while g.get(e).is_some_and(SGlyph::is_space) { + e += 1; + } + if e >= g.len() || g[e].ch == '\u{2028}' { + return true; + } + let Some(last) = e.checked_sub(1).and_then(|k| g.get(k)) else { return false }; + last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e) +} + /// Would a line ending at glyph index `end` split a No Break run? fn splits_no_break(g: &[SGlyph], end: usize) -> bool { end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) @@ -539,7 +593,8 @@ const PUSH_IN_RUN: usize = 16; /// neither does a line holding a tab or right-to-left text, nor one where the characters taken in /// reach a space after a character that may not end a line. With `hanging` (Optical Margin /// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not -/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). +/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). Both lines are +/// measured with their last character set as narrow as `line_end` sets it ([`break_line`]). #[allow(clippy::too_many_arguments)] fn push_in( g: &[SGlyph], @@ -553,6 +608,7 @@ fn push_in( kinsoku_type: KinsokuShoriType, hanging: bool, vertical: bool, + line_end: &[f64], ) -> Option { if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { return None; @@ -604,7 +660,7 @@ fn push_in( || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); - if x - width > room + EPS { + if x - line_end_credit(g, line_end, last) - width > room + EPS { return None; } if kinsoku_type == KinsokuShoriType::PushOutFirst { @@ -612,7 +668,8 @@ fn push_in( while kept > i && g[kept - 1].is_space() { kept -= 1; } - let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); + let set = if kept > i { line_end_credit(g, line_end, kept) } else { 0.0 }; + let short = width - (g[i..kept].iter().map(|h| h.adv).sum::() - set); if short <= squeeze_em(before, vertical) + EPS { return None; } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index da264d26e..0fd3c1275 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -23,11 +23,14 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000; /// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, /// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the /// greedy single-line composer. With `continued`, the paragraph continues from the frame before, -/// and its first composed line is not its first line. +/// and its first composed line is not its first line. `line_end` is how much narrower a line +/// ending with each glyph is set ([`ParagraphData::line_end`]). +#[allow(clippy::too_many_arguments)] fn compose_para( cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], + line_end: &[f64], para: &ParaStyle, pen: &Pen<'_>, rtl: bool, @@ -68,6 +71,12 @@ fn compose_para( return None; } let total: f64 = sg.iter().map(|g| g.adv).sum(); + // The same rule as the line loop below: an opening bracket starting a wrapped line gives up + // the space before it. + let start_credit = |i: usize| { + let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; + sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) + }; let mut sim = pen.clone(); let mut widths = vec![]; let mut acc = 0.0; @@ -77,8 +86,9 @@ fn compose_para( let first = widths.is_empty(); let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed - // lines need: each fits its width, so its first word does too. - let word = if first { word_width(sg, 0) } else { 0.0 }; + // lines need: each fits its width, so its first word does too. It is measured as the line + // loop measures it, with Mojikumi's narrower start and end. + let word = if first { word_width(sg, 0, line_end, para.kinsoku) - start_credit(0) } else { 0.0 }; let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break }; let w = x1 - x0 - ind_l - para.right_indent; widths.push(w); @@ -103,13 +113,10 @@ fn compose_para( // stop at four times its stretch and fall back to the single-line composer; the documented // default range (word spacing up to 133%) is narrow enough that most paragraphs would. let (strict, loose) = (1.0, f64::INFINITY); - // The same rule as the line loop below: an opening bracket starting a wrapped line gives up - // the space before it. - let start_credit = |i: usize| { - let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; - sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) - }; - compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) + // A line's last character is set narrower, as the line loop sets it. + let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); + let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p); + pass(¶ms(strict)).or_else(|| pass(¶ms(loose))) } /// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers @@ -184,6 +191,10 @@ struct ParagraphData<'a, 'text> { sg: Vec, compressed: Vec, wakan: Vec, + /// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring + /// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi + /// None, or right-to-left text). + line_end: Vec, flex: Vec, marker: usize, pm: Metrics, @@ -255,7 +266,13 @@ fn prepare_paragraph<'a, 'text>( if pi > 0 { pen.pending += para.space_before; } - let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued); + // A line fits when it fits as it is set: with its last character narrowed by Mojikumi. + let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl { + (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect() + } else { + vec![] + }; + let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued); // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the // Hyphen Limit counts the lines in a row that end with a hyphen. let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; @@ -271,6 +288,7 @@ fn prepare_paragraph<'a, 'text>( sg, compressed, wakan, + line_end, flex, marker, pm, @@ -286,6 +304,8 @@ struct PlacementContext<'a> { para: &'a ParaStyle, pr: &'a Range, sg: &'a mut [SGlyph], + /// [`ParagraphData::line_end`]. + line_end: &'a [f64], continued: bool, rtl: bool, mirror: bool, @@ -314,14 +334,27 @@ struct LinePosition { } fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { - let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } = - context; + let PlacementContext { + para, + pr, + sg, + line_end, + continued, + rtl, + mirror, + pm, + composed, + hyphen_run, + hyphen_limit, + zone, + index: i, + para_line, + hanging, + } = context; let n = sg.len(); let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; let (ind_l, ind_r) = line_indents(para, first_line, *mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(sg, *i); // Mojikumi: an opening bracket starting a line is set flush with the line's start (the // space before it goes), and the line has that much more room. At a paragraph's start // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). @@ -334,6 +367,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC glyph.adv -= half; glyph.lead += half; } + // The line's first word, as narrow as the line sets it at both ends. + let word = word_width(sg, *i, line_end, para.kinsoku); // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { @@ -347,8 +382,23 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { - let line = - |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); + let line = |ty| { + break_line( + cx.text, + sg, + *i, + width, + ¶.tabs, + ind_l, + hyphenate, + para.burasagari, + para.kinsoku, + ty, + *hanging, + cx.vertical, + line_end, + ) + }; let (end, hyph) = line(para.kinsoku_type); // The line pushed characters in where it ends after the line that pushes out. let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; @@ -441,6 +491,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut para: paragraph.para, pr: ¶graph.pr, sg: &mut paragraph.sg, + line_end: ¶graph.line_end, continued: paragraph.continued, rtl: paragraph.rtl, mirror: paragraph.mirror, @@ -475,6 +526,18 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } +/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of +/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it +/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space +/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0 +/// for an advance or a space that is not finite. +pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 { + let Some(g) = sg.get(k) else { return 0.0 }; + let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) }; + let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0); + if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 } +} + /// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it /// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or /// stay out of justification, and whether it is the paragraph's last line. @@ -490,12 +553,10 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData trimmed -= 1; } let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| { - let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0) - }); + let end_trim = trimmed + .checked_sub(1) + .filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf) + .map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, k)); let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 920e802b7..e077707a4 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -92,14 +92,15 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() { KinsokuShoriType::PushOutOnly, false, false, + &[], ); assert_eq!((end, hyphen), (4, true), "{tag}"); let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 }; - let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition"); + let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition"); assert!(lines.iter().any(|(_, hyphen)| *hyphen), "{tag}"); assert!(lines.iter().all(|(end, _)| end % 2 == 0), "{tag}: {lines:?}"); let limited = Params { hyphen_limit: 1, ..params }; - if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &limited) { + if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &limited) { assert!(lines.windows(2).all(|pair| !pair[0].1 || !pair[1].1), "{tag}: {lines:?}"); } } @@ -164,7 +165,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); let (end, hyphenated) = - break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -178,12 +179,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); let (first_end, _) = - break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = - break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -239,7 +240,7 @@ fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); let (end, hyphen) = - break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -248,7 +249,7 @@ fn no_break_keeps_marked_characters_on_one_line() { g.no_break = true; } let (end, hyphen) = - break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -257,15 +258,60 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + let (end, _) = + break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + let (end, _) = + break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); } +/// A soft hyphen inside a No Break run is not a place a line may break, so the word is measured +/// across it, as wide as [`break_line`] keeps it. +#[test] +fn word_width_measures_across_a_soft_hyphen_inside_a_no_break_run() { + let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 1, 10.0, 'B'), (4, 1, 10.0, 'C')]); + // The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt. + for g in &mut glyphs { + g.sx = 0.001; + } + let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None); + assert!(plain < 30.0, "without the mark the word ends at the soft hyphen: {plain}"); + for g in &mut glyphs { + g.no_break = true; + } + let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None); + assert!((marked - 30.0).abs() < 1e-9, "the marked run is measured whole: {marked}"); +} + +/// A forced line break ends the word [`breaking::word_width`] measures, inside a No Break run too, +/// and a soft hyphen just before it adds no hyphen, as [`break_line`] sets the line. +#[test] +fn word_width_stops_at_a_forced_line_break() { + let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 3, 5.0, '\u{2028}'), (4, 1, 10.0, 'B'), (5, 1, 10.0, 'C')]); + for g in &mut glyphs { + g.no_break = true; + } + let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None); + assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}"); + + let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 3, 5.0, '\u{2028}'), (6, 1, 10.0, 'B')]); + // The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt. + for g in &mut glyphs { + g.sx = 0.001; + } + let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None); + assert!((plain - 10.0).abs() < 1e-9, "no hyphen before the forced line break: {plain}"); + for g in &mut glyphs { + g.no_break = true; + } + let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None); + assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}"); +} + /// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em. fn full_width(text: &str) -> Vec { let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); @@ -286,7 +332,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -351,7 +397,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { let mixed = "一二三א五六七八九十一二三四五」六"; let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); - let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + let mixed_end = + |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); @@ -362,7 +409,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { for g in &mut glyphs { g.sy *= 2.0; } - let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + let tall_end = + |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to @@ -371,7 +419,97 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { glyphs[1].adv = 5.0; let half = glyphs[1].clone(); glyphs.insert(2, half); - let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0; + let split_end = + |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0; assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } + +/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed +/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last +/// glyph of the last cluster counts, and only where the line may end. +#[test] +fn greedy_wrap_counts_the_narrower_line_end() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let text = "一二三」四五六"; + let glyphs = full_width(text); + let mut credit = vec![0.0; glyphs.len()]; + credit[3] = 5.0; + let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| { + break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + }; + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits"); + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it"); + assert_eq!(end(&glyphs, 34.0, Burasagari::None, &credit), 2, "half width is still too wide"); + // A credit that isn't finite counts for nothing, and one larger than the glyph for its advance. + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::INFINITY]), 2); + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::NAN]), 2); + assert_eq!(end(&glyphs, 30.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 4, "at most 10 pt off"); + assert_eq!(end(&glyphs, 29.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 2); + + // The line may not end after 三 (、 follows it): its credit does not keep it on the line, so 、 + // does not hang after a line already wider than the measure. + let comma = "一二三、"; + let glyphs = full_width(comma); + let end = |line_end: &[f64]| { + break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + }; + assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2); + + // Kinsoku Shori Type measures both lines as they are set. Pushing 。 in leaves the line 5 pt + // wider than the measure with 。 half width, which its five gaps close. + let pushed = "一二三四」。六"; + let glyphs = full_width(pushed); + let end = |ty: KinsokuShoriType, line_end: &[f64]| { + break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0 + }; + let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0]; + assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in"); + assert_eq!(end(PushInFirst, &[]), 3, "full width, the gaps can't close enough"); + // Push Out First: pushing out to after 」, set half width, leaves the line 15 pt short, more + // than an em, so 四 is pushed in. + let pushed = format!("{}二」「四", "一".repeat(10)); + let glyphs = full_width(&pushed); + let end = |line_end: &[f64]| { + break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0 + }; + let mut bracket = vec![0.0; glyphs.len()]; + bracket[11] = 5.0; + assert_eq!(end(&bracket), 14, "四 is pushed in"); + assert_eq!(end(&[]), 12, "full width, pushing out leaves the line one em short: 「四 go on"); + + // 三 is two glyphs of one cluster: the credit of its first glyph does not count. + let text = "一二三"; + let mut glyphs = full_width(text); + glyphs[2].adv = 5.0; + let half = glyphs[2].clone(); + glyphs.insert(3, half); + let end = + |line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0; + assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line"); + assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line"); +} + +/// Mojikumi: the every-line composer measures each candidate line with its last character narrowed +/// (`end_credit`), never by more than that glyph's advance, and a line ending in a hyphen ends with +/// the hyphen. +#[test] +fn composition_counts_the_narrower_line_end() { + let text = "一二三」四五六"; + let glyphs = full_width(text); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let lines = + |width: f64, end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint]| compose(&glyphs, &|_| width, &|_| 0.0, end_credit, cands, &[], ¶ms); + let half = |t: usize| if t == 4 { 5.0 } else { 0.0 }; + assert_eq!(lines(35.0, &half, &cands), Some(vec![(4, false), (7, false)])); + let solid = lines(35.0, &|_| 0.0, &cands).expect("composed"); + assert!(solid.iter().all(|&(end, _)| end != 4), "」 full width does not end the first line: {solid:?}"); + // At most the glyph's advance; a credit that isn't finite counts for nothing. + assert_eq!(lines(30.0, &|t| if t == 4 { 100.0 } else { 0.0 }, &cands).and_then(|l| l.first().copied()), Some((4, false))); + assert!(lines(35.0, &|_| f64::NAN, &cands).is_some_and(|l| l.iter().all(|&(end, _)| end != 4))); + // No credit for a line ending in a hyphen. + let hyphenated = [Breakpoint { end: 4, hyphen: 1.0 }]; + assert_eq!(lines(38.0, &half, &hyphenated), None); + assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); +} diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index aab7f0e1f..1300920ad 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -414,6 +414,41 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { Some(builder.build()) } +/// The family of [`full_width_font`]. +pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans"; + +/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide. +pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。"; + +/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of +/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep +/// their own glyphs. +pub fn full_width_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let wide = write_fonts::types::GlyphId::new(charmap.map('\u{2318}')?.to_u32()); + let latin = ('A'..='Z').chain('a'..='z').chain([' ']); + let mut mappings: Vec<(char, write_fonts::types::GlyphId)> = FULL_WIDTH_CHARS.chars().map(|c| (c, wide)).collect(); + for c in latin { + mappings.push((c, write_fonts::types::GlyphId::new(charmap.map(c)?.to_u32()))); + } + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[(1, FULL_WIDTH_FAMILY), (2, "Regular"), (4, "Fullwidthtest Sans Regular"), (6, "FullwidthtestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GSUB") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`half_form_font`]. pub const HALF_FORM_FAMILY: &str = "Halftest Sans"; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 2bc1c2ff7..e76aee72f 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -1075,6 +1075,176 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() { } } +/// Mojikumi: with Line-end Punctuation Half Width, a line is measured as it is set. A closing +/// bracket, comma or full stop that fits only at half width ends the line in a measure of three +/// and a half ems, with both composers, horizontal and vertical; with Mojikumi None it goes to the +/// next line with the character before it. A closing mark that already lost its half to the +/// punctuation after it takes nothing more off, and the space between Japanese and Latin text +/// counts only where the line ends. Uses a test font whose ideographs and punctuation are one em. +#[test] +fn mojikumi_line_end_half_width_counts_when_a_line_breaks() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi, composer: Composer, burasagari: Burasagari, vertical_type: bool, measure: f64| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.mojikumi = m; + t.para.composer = composer; + t.para.burasagari = burasagari; + t.para.kinsoku = Kinsoku::Hard; + // Push Out Only lets the every-line composer take the paragraph. + if composer == Composer::EveryLine { + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + } + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(&db, &t) + }; + let ends = |l: &TextLayout, text: &str| l.lines.iter().map(|line| text[line.start..line.end].to_string()).collect::>(); + let widest = |l: &TextLayout| { + l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::()).fold(0.0, f64::max) + }; + assert!((lay("一", Mojikumi::None, Composer::SingleLine, Burasagari::None, false, 70.0).glyphs[0].advance - 20.0).abs() < 1e-9, "one em"); + for vertical_type in [false, true] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let at = format!("vertical {vertical_type} {composer:?}"); + let marks = [("一二三」四五六", "一二三」"), ("一二三。四五六", "一二三。"), ("一二三、四五六", "一二三、")]; + for (text, first) in marks { + let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&l, text), [first, "四五六"], "{at}: {text}"); + assert!((l.glyphs[3].advance - 10.0).abs() < 1e-9, "{at}: {text}: the mark ends the line half width"); + assert!(widest(&l) <= 70.0 + 1e-9, "{at}: {text}: {}", widest(&l)); + // Standard burasagari: the mark fits inside, so it doesn't hang. + let standard = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::Standard, vertical_type, 70.0); + assert_eq!(ends(&standard, text), [first, "四五六"], "{at}: {text} standard"); + assert!(widest(&standard) <= 70.0 + 1e-9, "{at}: {text} standard"); + // Full width it doesn't fit: kinsoku takes 三 to the next line with it. + let solid = lay(text, Mojikumi::None, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&solid, text).first().map(String::as_str), Some("一二"), "{at}: {text} none"); + } + // 」 lost its half to 。 after it: the line would be four ems, so 三」。 go on. + let text = "一二三」。四五"; + let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&l, text).first().map(String::as_str), Some("一二"), "{at}: compressed"); + assert!(widest(&l) <= 70.0 + 1e-9, "{at}: compressed"); + } + } + // The quarter em between 三 and A goes where the line ends there: 一二三 fits three ems and a + // tenth, with or without the space at its end. + for composer in [Composer::SingleLine, Composer::EveryLine] { + let text = "一二三ABC"; + let on = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 62.0); + assert_eq!(ends(&on, text).first().map(String::as_str), Some("一二三"), "{composer:?}"); + assert!((on.glyphs[2].advance - 20.0).abs() < 1e-9, "{composer:?}: no space at the line's end"); + let wide = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 200.0); + assert!((wide.glyphs[2].advance - 25.0).abs() < 1e-9, "{composer:?}: the space inside the line"); + } +} + +/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top, +/// a line's first word is measured as the line sets it. A word ending with a closing bracket that +/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them +/// only flush with the line's start, start on the first line, with both composers. An opening +/// bracket that kinsoku keeps with the character after it, and a No Break run, are measured whole. +#[test] +fn mojikumi_narrows_a_lines_first_word_when_looking_for_room() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Composer, Kinsoku, KinsokuShoriType, Mojikumi, TextWrap, WrapShape}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + // The top 40 pt of the frame are `top` wide, the rest 200: an L-shaped frame, or a rectangle + // with a wrap object over the top right. + let lay = |text: &str, styled: fn(&mut TextObject), composer: Composer, top: f64, wrapped: bool| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + styled(&mut t); + t.para.mojikumi = Mojikumi::LineEndHalf; + t.para.composer = composer; + t.para.kinsoku = Kinsoku::Hard; + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + let frame = if wrapped { + t.wrap = vec![WrapShape { + path: PathData::from_bezpath(&Rect::new(top, -10.0, 210.0, 40.0).to_path(0.1)), + wrap: TextWrap { offset: 0.0, invert: false }, + }]; + Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1) + } else { + let mut p = kurbo::BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((top, 0.0)); + p.line_to((top, 40.0)); + p.line_to((200.0, 40.0)); + p.line_to((200.0, 200.0)); + p.line_to((0.0, 200.0)); + p.close_path(); + p + }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + layout(&db, &t) + }; + let plain: fn(&mut TextObject) = |_| {}; + let wide_bracket: fn(&mut TextObject) = |t| edit::style_range(&mut t.runs, 0, '「'.len_utf8(), |s| s.h_scale = 250.0); + let no_break: fn(&mut TextObject) = |t| { + let n = t.plain_text().len(); + edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true); + }; + let no_break_but_the_last_word: fn(&mut TextObject) = |t| { + let n = t.plain_text().rfind(' ').map_or(0, |k| k + 1); + edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true); + }; + // "ABCD」" is `full` wide inside a line; ending one, 」 is half width. + let closing = "ABCD」EFGH"; + let open = lay(closing, plain, Composer::SingleLine, 200.0, false); + let full: f64 = open.glyphs[..5].iter().map(|g| g.advance).sum(); + assert!((open.glyphs[4].advance - 20.0).abs() < 1e-9, "」 is one em inside a line"); + for wrapped in [false, true] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let at = format!("wrapped {wrapped} {composer:?}"); + let top = full - 5.0; + let l = lay(closing, plain, composer, top, wrapped); + let first = l.lines.first().expect("a line"); + assert_eq!(&closing[first.start..first.end], "ABCD」", "{at}"); + assert!(first.baseline < 40.0, "{at}: the word starts on the narrow lines: {}", first.baseline); + let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum(); + assert!(set <= top + 1e-9, "{at}: {set} in {top}"); + // 「 is 50 pt at 250%, 25 pt flush with the line's start: 「一 is 45 pt. + let opening = "「一二三四五六"; + let l = lay(opening, wide_bracket, composer, 47.0, wrapped); + let first = l.lines.first().expect("a line"); + assert_eq!(&opening[first.start..first.end], "「一", "{at}"); + assert!(first.baseline < 40.0, "{at}: 「 starts on the narrow lines: {}", first.baseline); + let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum(); + assert!(set <= 47.0 + 1e-9, "{at}: {set}"); + // Kinsoku keeps 「 with 一: in 40 pt they don't fit, and the line starts below, whole. + let l = lay(opening, wide_bracket, composer, 40.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline > 40.0 && &opening[first.start..first.end] != "「", "{at}: kinsoku: {:?}", placed_lines(&l, opening)); + // A No Break run is one word, its space included: it doesn't fit the narrow lines. + let run_text = "ABCD EFGH"; + let run: f64 = lay(run_text, no_break, composer, 200.0, false).glyphs.iter().map(|g| g.advance).sum(); + let l = lay(run_text, no_break, composer, run - 5.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline > 40.0 && &run_text[first.start..first.end] == run_text, "{at}: no break: {:?}", placed_lines(&l, run_text)); + // …but the space ending a No Break run is not set at the line's end: the run fits a + // line 2 pt wider than it is without that space. + let ending = "ABCD EFGH IJ"; + let l = lay(ending, no_break_but_the_last_word, composer, run + 2.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline < 40.0 && ending[first.start..first.end].trim_end() == run_text, "{at}: {:?}", placed_lines(&l, ending)); + } + } +} + +/// Each line's baseline and text. +fn placed_lines(l: &TextLayout, text: &str) -> Vec<(f64, String)> { + l.lines.iter().map(|line| (line.baseline, text[line.start..line.end].to_string())).collect() +} + /// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to /// the next line with the character before it (None) or hangs outside the line (Standard, Forced); /// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 5606cb884..1530caf10 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -17,6 +17,8 @@ pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; +mod shadow; +pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; @@ -672,6 +674,19 @@ impl HoleSearch { } } +/// How sanitized Revolve options `o` project a profile with `bounds`: about the axis at the +/// profile's middle height, with the eye as far as the swept radius or the height needs. +fn revolve_projection(bounds: Rect, o: Revolve) -> shadow::Projection { + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = (bounds.y0 + bounds.y1) * 0.5; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); + shadow::Projection { view: o, origin: Point::new(axis, center_y), eye } +} + /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, /// each turned out of the region `rule` fills. fn revolve_faces( @@ -697,13 +712,7 @@ fn revolve_faces( if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); } - let axis = match o.edge { - Edge::Left => bounds.x0 - o.offset, - Edge::Right => bounds.x1 + o.offset, - }; - let center_y = (bounds.y0 + bounds.y1) * 0.5; - let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); - let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); + let shadow::Projection { origin: Point { x: axis, y: center_y }, eye, .. } = revolve_projection(bounds, o); let steps = o.steps(); let sweep = o.angle.to_radians(); let smooth = o.shade && o.smooth_shading; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index d1970c202..b12bd315f 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -56,12 +56,12 @@ impl Mesh { /// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { - extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces)) + extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces)) } /// Rotate's plane ([`crate::plane`]) as a mesh. pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { - plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces)) + plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces)) } /// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one @@ -72,7 +72,7 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces))) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs new file mode 100644 index 000000000..d7cd01a86 --- /dev/null +++ b/crates/three-d/src/shadow.rs @@ -0,0 +1,343 @@ +//! The shadow a 3D effect's solid casts: its faces projected along the first light onto a plane +//! behind the object (square to the view) or below it (under the object's own vertical axis, turned +//! with it), as one hard-edged vector shape. +//! +//! Every face is projected whole, so the shape is the union of the projected faces: each contour is +//! turned so that its face winds the same way, and the shape is filled with the nonzero rule. The +//! plane ends at a square about the foot of the object's center, so a light that grazes the plane +//! does not cast a shadow without end. + +use std::ops::Range; + +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; + +use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; + +/// Where the shadow falls. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ShadowPosition { + /// On a plane behind the object, square to the view. + #[default] + Behind, + /// On a plane below the object, square to its vertical axis and turned with it. + Below, +} + +impl ShadowPosition { + pub const ALL: [ShadowPosition; 2] = [Self::Behind, Self::Below]; + + pub fn id(self) -> &'static str { + match self { + Self::Behind => "behind", + Self::Below => "below", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|p| p.id() == s) + } +} + +/// A shadow's settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shadow { + pub position: ShadowPosition, + /// How far the plane is from the object, as a share (0..1) of the object's largest size. + pub distance: f64, + /// How far the plane reaches, as a share (0.1..4): at 1 the plane is a square four times the + /// object's largest size across. + pub bounds: f64, +} + +impl Default for Shadow { + fn default() -> Self { + Self { position: ShadowPosition::Behind, distance: 0.0, bounds: 1.0 } + } +} + +impl Shadow { + fn sanitized(self) -> Self { + Self { distance: finite(self.distance, 0.0, 0.0, 1.0), bounds: finite(self.bounds, 1.0, 0.1, 4.0), ..self } + } +} + +/// The rotation and projection a solid is drawn with. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Projection { + /// The sanitized view: its rotation, perspective and lights. + pub(crate) view: Revolve, + /// Where the view's origin is on the page. + pub(crate) origin: Point, + /// The eye's distance in front of the origin; `None` without perspective. + pub(crate) eye: Option, +} + +impl Projection { + fn project(&self, v: V3) -> Point { + let f = perspective_scale(self.eye, v.2); + Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f) + } + + /// The unit direction toward the first light, in view space; `None` when it lights nothing: + /// Shading is off or its intensity is 0. + fn light(&self) -> Option { + let first = self.view.lights.and_then(|l| l.iter().next().copied()); + let intensity = first.map_or(self.view.light_intensity, |l| l.intensity); + if !self.view.shade || intensity <= 0.0 { + return None; + } + let [x, y, z] = first.map_or_else(|| self.view.light_direction(), |l| l.direction()); + Some(V3(x, y, z).unit()) + } +} + +/// The light must reach the plane at least this steeply (its cosine to the plane's normal). +const MIN_LIGHT: f64 = 1e-3; + +/// Most faces one object's shadow is cast from, its solids together: as many as two of the largest +/// solids. +pub const MAX_SHADOW_FACES: usize = 2 * MAX_SOLID_FACES; + +/// The error a solid added past [`MAX_SHADOW_FACES`] returns. +pub const TOO_COMPLEX_SHADOW: &str = "The 3D shadow is too complex; turn Shadows off or simplify the art"; + +/// The plane a shadow falls on ([`ShadowCaster::shadow_plane`]), placed once for the pieces of one +/// object's shadow ([`ShadowCaster::path_on`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowPlane { + projection: Projection, + /// The unit direction toward the light, and its cosine to the plane's normal. + light: V3, + reach: f64, + /// The plane's unit normal toward the object, and two unit axes along it. + n: V3, + u: V3, + w: V3, + /// Where the plane is along its normal, and the foot of the object's center on it. + level: f64, + foot: V3, + /// Half the side of the square the plane ends at. + half: f64, +} + +/// The faces of one object's solids, gathered to cast one shadow. The solids of one object share +/// their bounds and view, so they share a projection: the first solid's is kept. Each solid added +/// returns the range of its faces, so that the shadow of some of them can be drawn on the plane +/// of others ([`ShadowCaster::shadow_plane`], [`ShadowCaster::path_on`]). +#[derive(Clone, Debug, Default)] +pub struct ShadowCaster { + faces: Vec, + projection: Option, +} + +impl ShadowCaster { + pub fn new() -> Self { + Self::default() + } + + /// How many faces have been gathered. + pub fn len(&self) -> usize { + self.faces.len() + } + + pub fn is_empty(&self) -> bool { + self.faces.is_empty() + } + + /// Keep `faces`, or an error when they would take the faces gathered past [`MAX_SHADOW_FACES`]. + fn add(&mut self, faces: Vec, projection: Option) -> Result, String> { + let start = self.faces.len(); + if start.saturating_add(faces.len()) > MAX_SHADOW_FACES { + return Err(TOO_COMPLEX_SHADOW.into()); + } + if !faces.is_empty() && self.projection.is_none() { + self.projection = projection; + } + self.faces.extend(faces); + Ok(start..self.faces.len()) + } + + /// Add Extrude & Bevel's solid ([`crate::extrude`]). + pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { + let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?; + self.add(faces, projection) + } + + /// Add Rotate's plane ([`crate::plane`]). + pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { + let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?; + self.add(faces, projection) + } + + /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each + /// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids + /// drawn, but the faces of the others are not kept (their range is empty). A shape that does + /// not inflate is left out, as its solid is; the result says which, in order. + pub fn inflate( + &mut self, + shapes: &[(&PathData, FillRule)], + casts: &[bool], + bounds: Rect, + options: Inflate, + view: View, + ) -> Vec, String>> { + inflate_faces(shapes, bounds, options, view, true) + .enumerate() + .map(|(i, r)| { + let (_, faces, projection) = r?; + if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + }) + .collect() + } + + /// Add Revolve's surface ([`crate::revolve`]). + pub fn revolve(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result, String> { + let mut faces = vec![]; + crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?; + let o = options.sanitized(); + self.add(faces, Some(crate::revolve_projection(bounds, o))) + } + + /// The shadow of the faces gathered, on the page, filled with the nonzero rule: `None` when + /// there are none, when the first light lights nothing (Shading is off or its intensity is 0) + /// or does not reach the plane (it is behind the object for a shadow behind it, or below the + /// plane for one below), or when nothing of the shadow falls within the plane. + pub fn path(&self, shadow: Shadow) -> Option { + let all = 0..self.faces.len(); + let all = std::slice::from_ref(&all); + self.path_on(&self.shadow_plane(shadow, all)?, all) + } + + /// The faces in the ranges `faces`, from the faces gathered. + fn within(&self, faces: &[Range]) -> impl Iterator { + faces.iter().flat_map(|r| self.faces.get(r.clone()).unwrap_or_default()) + } + + /// The plane `shadow` falls on for the faces in the ranges `faces` (its distance, size and + /// center follow from them), for [`ShadowCaster::path_on`]: `None` when there are none or when + /// the first light lights nothing or does not reach the plane. + pub fn shadow_plane(&self, shadow: Shadow, faces: &[Range]) -> Option { + let p = self.projection?; + let light = p.light()?; + let s = shadow.sanitized(); + let corners = || self.within(faces).flat_map(|(contours, _)| contours.iter().flatten()); + let first = *corners().next()?; + let (lo, hi) = corners() + .fold((first, first), |(lo, hi), v| (V3(lo.0.min(v.0), lo.1.min(v.1), lo.2.min(v.2)), V3(hi.0.max(v.0), hi.1.max(v.1), hi.2.max(v.2)))); + let size = (hi.0 - lo.0).max(hi.1 - lo.1).max(hi.2 - lo.2).max(1e-9); + // The plane's normal toward the object, and two axes along it. + let (n, u, w) = match s.position { + ShadowPosition::Behind => (V3(0.0, 0.0, 1.0), V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)), + ShadowPosition::Below => { + let r = |v: V3| p.view.rotate(v).unit(); + (r(V3(0.0, -1.0, 0.0)), r(V3(1.0, 0.0, 0.0)), r(V3(0.0, 0.0, 1.0))) + } + }; + let reach = n.dot(light); + if !reach.is_finite() || reach < MIN_LIGHT { + return None; + } + // The plane: every corner at least `distance` × size in front of it. + let level = corners().map(|v| n.dot(*v)).fold(f64::INFINITY, f64::min) - s.distance * size; + let centre = V3((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0, (lo.2 + hi.2) / 2.0); + let foot = add(centre, scale(n, level - n.dot(centre))); + Some(ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half: 2.0 * s.bounds * size }) + } + + /// The shadow the faces in the ranges `faces` cast on `plane`, on the page, filled with the + /// nonzero rule; `None` when nothing of it falls within the plane. + pub fn path_on(&self, plane: &ShadowPlane, faces: &[Range]) -> Option { + let &ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half } = plane; + // A corner's shadow, in the plane's axes about the foot. + let cast = |v: V3| { + let q = add(v, scale(light, -(n.dot(v) - level) / reach)); + let d = q.sub(foot); + Point::new(u.dot(d), w.dot(d)) + }; + let to_page = |q: Point| p.project(add(foot, add(scale(u, q.x), scale(w, q.y)))); + let mut bp = BezPath::new(); + for (contours, _) in self.within(faces) { + let flat: Vec> = contours.iter().map(|c| c.iter().map(|v| cast(*v)).collect()).collect(); + let turn: f64 = flat.iter().map(|c| signed_area(c)).sum(); + // A face edge-on to the light casts nothing. + if flat_area(turn, flat.iter().flatten()) { + continue; + } + for c in &flat { + let mut clipped = clip(c, half); + if turn < 0.0 { + clipped.reverse(); + } + // A plane seen edge-on draws nothing. + let page: Vec = clipped.into_iter().map(to_page).collect(); + if flat_area(signed_area(&page), &page) { + continue; + } + let Some((start, rest)) = page.split_first() else { continue }; + bp.move_to(*start); + for q in rest { + bp.line_to(*q); + } + bp.close_path(); + } + } + let path = PathData::from_bezpath(&bp); + (!path.subpaths.is_empty()).then_some(path) + } +} + +/// Whether `area`, the signed area of a polygon with corners `points`, is within rounding of +/// nothing for its size (the square of its bounds' diagonal), or not a number, so the polygon draws +/// nothing. A long, narrow polygon keeps an area well above that. +fn flat_area<'a>(area: f64, points: impl IntoIterator) -> bool { + let mut points = points.into_iter(); + let Some(first) = points.next() else { return true }; + let bounds = points.fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p)); + let size = bounds.width().powi(2) + bounds.height().powi(2); + !area.is_finite() || !size.is_finite() || area.abs() <= 1e-14 * size +} + +fn add(a: V3, b: V3) -> V3 { + V3(a.0 + b.0, a.1 + b.1, a.2 + b.2) +} + +fn scale(a: V3, k: f64) -> V3 { + V3(a.0 * k, a.1 * k, a.2 * k) +} + +/// Twice the signed area of polygon `c` (positive counterclockwise with y up). +fn signed_area(c: &[Point]) -> f64 { + c.iter().zip(c.iter().cycle().skip(1)).take(c.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 +} + +/// Polygon `c` cut to the square from -`half` to `half` on both axes, edge by edge (the square is +/// convex, so each contour's winding inside it is kept). +fn clip(c: &[Point], half: f64) -> Vec { + let mut out: Vec = c.to_vec(); + // Each side: the axis it cuts across (x or y) and whether it is the upper one. + for (across_x, upper) in [(true, false), (true, true), (false, false), (false, true)] { + let coord = |p: Point| if across_x { p.x } else { p.y }; + let limit = if upper { half } else { -half }; + let inside = |p: Point| if upper { coord(p) <= limit } else { coord(p) >= limit }; + let input = std::mem::take(&mut out); + let Some(&last) = input.last() else { break }; + let mut prev = last; + for &cur in &input { + let (a, b) = (inside(prev), inside(cur)); + if a != b { + let (pc, cc) = (coord(prev), coord(cur)); + let t = (limit - pc) / (cc - pc); + out.push(prev.lerp(cur, t)); + } + if b { + out.push(cur); + } + prev = cur; + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs new file mode 100644 index 000000000..3fb376717 --- /dev/null +++ b/crates/three-d/src/shadow/tests.rs @@ -0,0 +1,187 @@ +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape}; + +use super::*; +use crate::{Extrude, Inflate, Light, Lights, Revolve, View}; + +fn square(x: f64, y: f64, s: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + s, y)); + b.line_to((x + s, y + s)); + b.line_to((x, y + s)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// The view straight on: no rotation, the default light. +fn front() -> View { + View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, ..View::default() } +} + +fn extruded(view: View, shadow: Shadow) -> Option { + let path = square(100.0, 100.0, 100.0); + let mut caster = ShadowCaster::new(); + caster.extrude(&path, FillRule::NonZero, Rect::new(100.0, 100.0, 200.0, 200.0), Extrude::default(), view).unwrap(); + caster.path(shadow) +} + +/// The area the shadow covers, sampled on a grid over `r` with the nonzero rule. +fn covered(path: &PathData, r: Rect, steps: usize) -> f64 { + let bp = path.to_bezpath(); + let (dx, dy) = (r.width() / steps as f64, r.height() / steps as f64); + let mut hits = 0; + for i in 0..steps { + for j in 0..steps { + let p = Point::new(r.x0 + (i as f64 + 0.5) * dx, r.y0 + (j as f64 + 0.5) * dy); + if bp.winding(p) != 0 { + hits += 1; + } + } + } + hits as f64 * dx * dy +} + +#[test] +fn a_light_straight_in_front_casts_the_silhouette_behind_the_object() { + let view = View { light_height: 90.0, ..front() }; + let path = extruded(view, Shadow::default()).expect("a shadow"); + let b = path.bounds().unwrap(); + for (got, want) in [(b.x0, 100.0), (b.y0, 100.0), (b.x1, 200.0), (b.y1, 200.0)] { + assert!((got - want).abs() < 1e-6, "{b:?}"); + } + // Every face of the box projects into the square, and the nonzero union fills it whole. + let area = covered(&path, Rect::new(90.0, 90.0, 210.0, 210.0), 120); + assert!((area - 10_000.0).abs() < 300.0, "{area}"); +} + +#[test] +fn the_shadow_behind_falls_away_from_the_light_and_moves_with_the_distance() { + // The default light is up and to the left, in front: the shadow falls down and to the right. + let near = extruded(front(), Shadow::default()).unwrap().bounds().unwrap(); + assert!(near.x1 > 200.0 + 1.0 && near.y1 > 200.0 + 1.0, "{near:?}"); + assert!(near.x0 >= 100.0 - 1e-6 && near.y0 >= 100.0 - 1e-6, "{near:?}"); + let far = extruded(front(), Shadow { distance: 1.0, ..Shadow::default() }).unwrap().bounds().unwrap(); + assert!(far.x1 > near.x1 + 10.0 && far.y1 > near.y1 + 10.0, "{near:?} {far:?}"); +} + +#[test] +fn a_light_behind_the_object_casts_no_shadow_behind_it() { + assert!(extruded(View { light_behind: true, ..front() }, Shadow::default()).is_none()); + let behind = Light { behind: true, ..Light::default() }; + let lights = Lights::new(&[behind, Light::default()]).unwrap(); + // The first light casts the shadow. + assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none()); + let lights = Lights::new(&[Light::default(), behind]).unwrap(); + assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_some()); +} + +#[test] +fn a_light_that_lights_nothing_casts_no_shadow() { + assert!(extruded(View { light_intensity: 0.0, ..front() }, Shadow::default()).is_none()); + assert!(extruded(View { shade: false, ..front() }, Shadow::default()).is_none()); + let dark = Light { intensity: 0.0, ..Light::default() }; + let lights = Lights::new(&[dark, Light::default()]).unwrap(); + assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none()); +} + +#[test] +fn the_shadow_below_lies_under_the_object_on_its_floor() { + let below = Shadow { position: ShadowPosition::Below, ..Shadow::default() }; + // Seen level, the floor is edge-on: nothing to draw. + assert!(extruded(front(), below).is_none()); + // Seen from above (the effects' default Off-Axis Front view), the floor shows. + let off_axis = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..View::default() }; + assert!(extruded(off_axis, below).is_some()); + // Seen from straight above, the floor is square to the view, under the object: with the light + // straight in front too, the shadow is the object's outline, as the one behind it is. + let top = View { rotation_x: -90.0, light_height: 90.0, ..front() }; + let under = extruded(top, below).unwrap().bounds().unwrap(); + let back = extruded(top, Shadow::default()).unwrap().bounds().unwrap(); + for (a, b) in [(under.x0, back.x0), (under.y0, back.y0), (under.x1, back.x1), (under.y1, back.y1)] { + assert!((a - b).abs() < 1e-6, "{under:?} {back:?}"); + } + // A light from below the floor casts nothing on it. + let from_below = View { light_rotation: -90.0, light_height: 0.0, ..off_axis }; + assert!(extruded(from_below, below).is_none()); +} + +#[test] +fn shadow_bounds_limit_the_plane_the_shadow_falls_on() { + // A low light casts a long shadow; the plane cuts it. + let low = View { light_height: 5.0, ..front() }; + let small = extruded(low, Shadow { bounds: 0.1, ..Shadow::default() }).unwrap().bounds().unwrap(); + let large = extruded(low, Shadow { bounds: 4.0, ..Shadow::default() }).unwrap().bounds().unwrap(); + // At 10 % the square reaches 0.2 × the size (100) from the foot of the center (150, 150). + for v in [small.x0, small.y0, small.x1, small.y1] { + assert!((130.0 - 1e-6..=170.0 + 1e-6).contains(&v), "{small:?}"); + } + assert!(large.width() > small.width() * 5.0, "{small:?} {large:?}"); + for v in [large.x0, large.y0, large.x1, large.y1] { + assert!((-650.0 - 1e-6..=950.0 + 1e-6).contains(&v), "{large:?}"); + } +} + +#[test] +fn bad_settings_are_sanitized_and_an_empty_caster_casts_nothing() { + assert!(ShadowCaster::new().path(Shadow::default()).is_none()); + let wild = Shadow { distance: f64::NAN, bounds: f64::INFINITY, ..Shadow::default() }; + assert_eq!(wild.sanitized(), Shadow { bounds: 1.0, ..Shadow::default() }); + assert_eq!(Shadow { distance: -3.0, bounds: 99.0, ..Shadow::default() }.sanitized(), Shadow { bounds: 4.0, ..Shadow::default() }); + assert_eq!(ShadowPosition::parse("below"), Some(ShadowPosition::Below)); + assert_eq!(ShadowPosition::parse("under"), None); +} + +#[test] +fn the_faces_one_shadow_is_cast_from_are_bounded() { + // An open profile of 500 points revolves into about 64,000 faces at 128 segments: five fit, + // the sixth does not. + let mut b = BezPath::new(); + b.move_to((110.0, 0.0)); + for i in 1..500 { + b.line_to((110.0 + f64::from(i % 2) * 5.0, f64::from(i))); + } + let path = PathData::from_bezpath(&b); + let bounds = path.bounds().unwrap(); + let options = Revolve { segments: 128, offset: 10.0, ..Revolve::default() }; + let mut caster = ShadowCaster::new(); + for _ in 0..5 { + let faces = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap(); + assert!(faces.len() > 60_000, "{faces:?}"); + } + let err = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap_err(); + assert!(err.contains("too complex"), "{err}"); + assert!(caster.len() <= MAX_SHADOW_FACES); + assert!(caster.path(Shadow::default()).is_some()); +} + +#[test] +fn a_small_solid_beside_a_huge_one_keeps_its_shadow() { + let bounds = Rect::new(0.0, 0.0, 1_000_010.0, 10.0); + let view = View { light_height: 90.0, ..front() }; + let mut caster = ShadowCaster::new(); + let small = caster.extrude(&square(0.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap(); + let huge = caster.extrude(&square(1_000_000.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap(); + let all = [small.clone(), huge]; + let plane = caster.shadow_plane(Shadow { bounds: 4.0, ..Shadow::default() }, &all).unwrap(); + let path = caster.path_on(&plane, std::slice::from_ref(&small)).expect("the small solid's shadow"); + let b = path.bounds().unwrap(); + assert!((b.width() - 10.0).abs() < 1e-3 && (b.height() - 10.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn every_effect_casts_a_shadow() { + let path = square(100.0, 100.0, 100.0); + let bounds = Rect::new(100.0, 100.0, 200.0, 200.0); + let mut plane = ShadowCaster::new(); + plane.plane(&path, FillRule::NonZero, bounds, View::default()).unwrap(); + assert!(plane.path(Shadow::default()).is_some()); + let mut inflate = ShadowCaster::new(); + let results = inflate.inflate(&[(&path, FillRule::NonZero)], &[true], bounds, Inflate::default(), View::default()); + assert!(results.iter().all(Result::is_ok)); + assert!(inflate.path(Shadow::default()).is_some()); + let mut revolve = ShadowCaster::new(); + revolve.revolve(&path, FillRule::NonZero, bounds, Revolve::default()).unwrap(); + let b = revolve.path(Shadow::default()).unwrap().bounds().unwrap(); + // The surface turns about the square's left edge, reaching 100 to either side of it. + assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); +} diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 66a22f80f..487055ccb 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -9,6 +9,7 @@ use super::{ Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale, }; +use crate::shadow::Projection; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -48,14 +49,19 @@ impl Camera { pub(super) fn brightness(&self, n: V3) -> f64 { crate::brightness(&self.view, n, self.light) } + /// The rotation and projection this camera draws with, for a shadow cast from its faces. + pub(super) fn projection(&self) -> Projection { + Projection { view: self.view, origin: self.origin, eye: self.eye } + } } /// A face of a solid before projection: its contours in view space (holes running the other way) /// and its outward normal. pub(crate) type Face3 = (Vec>, V3); -/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection. -pub(crate) type Built = (Vec, Vec); +/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection, +/// and the projection it used (`None` when nothing was built). +pub(crate) type Built = (Vec, Vec, Option); /// Facets being collected for one solid. pub(super) struct Builder<'a> { @@ -250,7 +256,7 @@ impl Builder<'_> { /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - (self.facets, self.mesh.unwrap_or_default()) + (self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection())) } } @@ -492,7 +498,7 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets) + extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets) } /// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. @@ -602,7 +608,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets) + plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets) } /// [`plane`], or with `mesh` its face before projection instead of its facet. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 20242697f..47ce3371d 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -62,7 +62,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect() + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect() } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. @@ -81,7 +81,7 @@ pub(crate) fn inflate_faces( let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); puffs.into_iter().map(move |p| match p { Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![])), + Ok(None) => Ok((vec![], vec![], None)), Err(e) => Err(e), }) } diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 1ed8df873..9501d9f83 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -230,24 +230,73 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { .collect(); // Reserve the spinner's space while idle too: starting a background job changes the text, // not its origin. Messages take priority over optional readouts in a narrow window. - let message = if let Some(job) = busy.first() { + let (message, notes) = if let Some(job) = busy.first() { let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0)); ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent)); let label = crate::i18n::msg(job); let more = busy.len() - 1; - if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") } + (if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }, &[][..]) } else { - crate::i18n::msg(&app.ui.status) + (crate::i18n::msg(&app.ui.status), status_notes(&app.ui)) }; if !message.is_empty() { let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom())); - status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text); + status_field_with_notes(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text, notes); } } +/// Most notes the status message's hover and access information list. +const MAX_LISTED_NOTES: usize = 30; + +/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when +/// there are several and the status bar still shows the message they came with, else none. +fn status_notes(ui: &crate::state::UiState) -> &[String] { + let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1); + notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default() +} + +/// `notes` in the interface language, one to a line, the first `most` of them (at most +/// [`MAX_LISTED_NOTES`]) and how many more. +pub(super) fn listed_notes(notes: &[String], most: usize) -> String { + let lang = crate::i18n::current(); + let most = most.min(MAX_LISTED_NOTES); + let mut lines: Vec = notes.iter().take(most).map(|n| format!("• {}", crate::i18n::message(lang, n))).collect(); + if notes.len() > most { + lines.push(format!("… (+{})", notes.len() - most)); + } + lines.join("\n") +} + +/// The hover of the status message `text` with `notes`: the message, then as many of the notes +/// ([`listed_notes`]) as fit in the window above or below the field `field`, wrapped within the +/// window's width. +fn notes_tooltip(response: egui::Response, field: egui::Rect, text: &str, notes: &[String]) -> egui::Response { + response.on_hover_ui(|ui| { + let screen = ui.ctx().content_rect(); + let margins = ui.spacing().menu_margin.sum() + 2.0 * ui.spacing().item_spacing; + let width = ui.spacing().tooltip_width.min(screen.width() - margins.x).max(0.0); + ui.set_max_width(width); + let room = (field.top() - screen.top()).max(screen.bottom() - field.bottom()) - 2.0 * margins.y; + let tip = |most: usize| format!("{text}\n{}", listed_notes(notes, most)); + let height = + |s: &str| egui::WidgetText::from(s).into_galley(ui, Some(egui::TextWrapMode::Wrap), width, egui::FontSelection::Default).size().y; + let mut most = notes.len().min(MAX_LISTED_NOTES); + while most > 0 && height(&tip(most)) > room { + most -= 1; + } + ui.add(egui::Label::new(tip(most)).wrap()); + }) +} + /// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access /// information retain the complete value when its on-screen label is truncated. fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) { + status_field_with_notes(ui, id, rect, text, font, color, &[]); +} + +/// [`status_field`], whose hover, when there are `notes`, lists them ([`notes_tooltip`]) and whose +/// access information has them after the text. +fn status_field_with_notes(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32, notes: &[String]) { if rect.width() <= 0.0 || rect.height() <= 0.0 { return; } @@ -261,9 +310,17 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let elided = galley.elided; ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color); let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); - if elided { - full_text_tooltip(response, text); + if notes.is_empty() { + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); + if elided { + full_text_tooltip(response, text); + } + return; + } + // The list is made only for a hover or a screen reader. + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}\n{}", listed_notes(notes, MAX_LISTED_NOTES)))); + if response.hovered() { + notes_tooltip(response, rect, &text, notes); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index a87b00045..2d18c3ddd 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -461,6 +461,65 @@ fn a_clipped_status_message_shows_in_full_on_hover() { assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); } +/// The status message of an open with several notes lists them all on hover, and screen readers +/// receive them after the message, while the status bar shows that message. A later message +/// leaves the list out, and so does an open with one note. +#[test] +fn the_message_of_an_open_with_several_notes_lists_them_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let notes = vec!["the file's layers weren't read".to_string(), "a brush wasn't read".to_string()]; + app.ui.status = crate::io::opened_with_notes(2, ¬es[0]); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let listed = "• the file's layers weren't read\n• a brush wasn't read"; + let full = format!("{}\n{listed}", app.ui.status); + let (texts, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar); + assert!(!status_text(&texts, "Opened").elided); + assert!(nodes.iter().any(|n| n.value() == Some(full.as_str())), "screen readers receive the notes: {nodes:?}"); + assert!(hovered.iter().any(|t| t.text == full), "the tooltip lists the notes: {hovered:?}"); + + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Saved", super::status_bar); + assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}"); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "no tooltip: {hovered:?}"); + + // One note is the message itself: no list. + app.ui.status = crate::io::opened_with_notes(1, "a brush wasn't read"); + app.ui.status_notes = Some((app.ui.status.clone(), vec!["a brush wasn't read".to_string()])); + let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar); + assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}"); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Opened")).count(), 1, "no tooltip: {hovered:?}"); +} + +/// The list has at most 30 notes, then how many more there are. +#[test] +fn the_hover_lists_thirty_notes_and_counts_the_rest() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let notes: Vec = (1..=32).map(|i| format!("note {i}")).collect(); + let listed = super::status_bar::listed_notes(¬es, usize::MAX); + let lines: Vec<&str> = listed.lines().collect(); + assert_eq!((lines.len(), lines[0], lines[29], lines[30]), (31, "• note 1", "• note 30", "… (+2)")); + assert_eq!(super::status_bar::listed_notes(¬es[..2], usize::MAX), "• note 1\n• note 2"); + assert_eq!(super::status_bar::listed_notes(¬es[..3], 1), "• note 1\n… (+2)"); +} + +/// A hover with more long notes than the window can show lists the ones that fit and how many +/// more there are, inside the window. A window 300 points tall is shorter than the main window's +/// smallest size (500 points) at UI Scaling 1.5. +#[test] +fn the_hover_of_many_notes_stays_inside_a_small_window() { + let (mut app, ctx) = status_readout_app(); + let notes: Vec = (1..=32).map(|i| format!("note {i}: {}", "this file's PostScript couldn't be read ".repeat(3))).collect(); + app.ui.status = crate::io::opened_with_notes(notes.len(), ¬es[0]); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let (_, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Opened", super::status_bar); + let tip: Vec<_> = hovered.iter().filter(|t| t.text.contains("\n• note 1:")).collect(); + let [tip] = &tip[..] else { panic!("one tooltip lists the notes: {hovered:?}") }; + assert!(tip.bounds.top() >= 0.0 && tip.bounds.bottom() <= 300.0 && tip.bounds.right() <= 700.0, "{tip:?}"); + let last = tip.text.lines().last().unwrap_or_default(); + assert!(last.starts_with("… (+") && !tip.text.contains("• note 30:"), "{last:?}"); +} + /// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on /// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider /// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f133a27cf..f9788d59a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ mod about; pub mod action_batch; +pub mod action_insert_menu_item; pub mod action_new; pub mod action_playback; pub mod action_set_options; @@ -391,6 +392,7 @@ registry! { ActionPlayback: [action_playback::KIND] => action_playback::SPEC, ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC, ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC, + ActionInsertMenuItem: [action_insert_menu_item::KIND] => action_insert_menu_item::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/action_insert_menu_item.rs b/crates/ui-egui/src/dialogs/action_insert_menu_item.rs new file mode 100644 index 000000000..2cf0ab612 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_insert_menu_item.rs @@ -0,0 +1,261 @@ +//! Actions panel menu › Insert Menu Item… (`ui.action.insertMenuItem`): the menu items that can be +//! steps of an action, to pick one from the list or find it by name. +//! +//! Its fields: `find` (the words the list is narrowed to), `command` and `params` (the item picked; +//! `command` is empty until one is), `note` (`none` or `several` when Find picked nothing), and +//! `set`, `action` and `step`, the place of the new step (no `set` and `action`: the recording in +//! progress). Picking an item runs nothing. OK inserts it; an invalid place is an error, and the +//! dialog stays open. + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, menus, widgets}; + +/// The dialog kind of Insert Menu Item. +pub const KIND: &str = "actionInsertMenuItem"; + +/// The most rows the list shows at once; Find narrows it. +const MAX_LISTED: usize = 200; + +/// The width of the Menu Item label. +const LABEL_W: f32 = 80.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Insert Menu Item").into(), + body, + confirm, + ok_enabled: Some(|_, d| !d.str("command").is_empty()), + min_width: 460.0, + ..DialogSpec::FORM +}; + +/// Opens Insert Menu Item for action `target` as (set, action), after `step`, else for the recording +/// in progress. +pub fn open(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option) { + let mut fields = json!({"find": "", "command": "", "params": {}, "note": ""}); + if let Some((set, action)) = target { + fields["set"] = json!(set); + fields["action"] = json!(action); + if let Some(step) = step { + fields["step"] = json!(step); + } + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// A row of the list: an item of [`menus::insertable_items`], with what it shows and what Find +/// matches worked out once. +pub(crate) struct Row { + command: &'static str, + params: Value, + /// The menu path and label in the UI language (`Object › Group`). + shown: String, + /// The English path and label, the shown one and the command id, lowercased, one per line. + haystack: String, +} + +/// The rows in `lang`, for menus that name fonts in English when `english_names` is set. +fn build(lang: crate::i18n::Lang, english_names: bool) -> Vec { + menus::insertable_items(english_names) + .into_iter() + .map(|item| { + let parts = || item.path.iter().chain(std::iter::once(&item.label)).copied(); + let english = parts().collect::>().join(" › "); + let shown = parts().map(|s| crate::i18n::tr(lang, s)).collect::>().join(" › "); + let haystack = format!("{}\n{}\n{}", english.to_lowercase(), shown.to_lowercase(), item.command.to_lowercase()); + Row { command: item.command, params: item.params, shown, haystack } + }) + .collect() +} + +/// The rows, built again only when the language, the installed plug-ins, the font name setting or +/// the loaded fonts change, not on every frame. +fn rows(app: &VectorcraftApp, ctx: &egui::Context) -> Arc> { + type Key = (&'static str, u64, bool, u64, bool); + let lang = crate::i18n::current(); + let english_names = app.session.prefs.font_names_in_english; + let db = vectorcraft_text::FontDb::global(); + // Read before building, as the Font menu does: fonts that load meanwhile build the rows again. + let (cataloged, generation) = (db.installed_fonts_ready(), db.generation()); + let key: Key = (lang.code(), menus::plugin_revision(), english_names, generation, cataloged); + let id = egui::Id::unique("action-insert-menu-item-rows"); + if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, Arc>)>(id)) + && k == key + { + return v; + } + let built = Arc::new(build(lang, english_names)); + ctx.data_mut(|d| d.insert_temp::<(Key, Arc>)>(id, (key, built.clone()))); + built +} + +/// The rows that every word of `find` matches, in menu order. +fn matches<'a>(rows: &'a [Row], find: &str) -> impl Iterator { + let words: Vec = find.to_lowercase().split_whitespace().map(str::to_string).collect(); + rows.iter().filter(move |r| words.iter().all(|w| r.haystack.contains(w.as_str()))) +} + +/// Picks `row` in `d`. +fn pick(d: &mut Dialog, row: &Row) { + d.fields.insert("command".into(), json!(row.command)); + d.fields.insert("params".into(), row.params.clone()); + d.fields.insert("note".into(), json!("")); +} + +/// Find: picks the one row that `find` matches; with none or several, picks nothing and notes it. +fn find(rows: &[Row], d: &mut Dialog) { + let found: Vec<&Row> = matches(rows, &d.str("find")).take(2).collect(); + match found[..] { + [row] => pick(d, row), + [] => { + d.fields.insert("note".into(), json!("none")); + } + _ => { + d.fields.insert("note".into(), json!("several")); + } + } +} + +/// Whether `row` is the item picked in `d`. +fn picked(d: &Dialog, row: &Row) -> bool { + d.fields.get("command").and_then(Value::as_str) == Some(row.command) && d.fields.get("params") == Some(&row.params) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let rows = rows(app, ui.ctx()); + let dim = Tokens::get(ui.ctx()).text_dim; + let mut find_now = false; + ui.horizontal(|ui| { + if super::form::text_named(ui, d, "find", tl!("Find"), 300.0) { + d.fields.insert("note".into(), json!("")); + } + find_now = widgets::secondary_button(ui, tl!("Find")).clicked(); + }); + if find_now { + find(&rows, d); + } + match d.str("note").as_str() { + "none" => { + ui.label(egui::RichText::new(tl!("No menu item matches.")).color(dim)); + } + "several" => { + ui.label(egui::RichText::new(tl!("Several menu items match. Choose one from the list.")).color(dim)); + } + _ => {} + } + ui.add_space(4.0); + let mut chosen: Option = None; + let query = d.str("find"); + egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, true]).show(ui, |ui| { + for (i, row) in matches(&rows, &query).enumerate().take(MAX_LISTED) { + if ui.selectable_label(picked(d, row), row.shown.as_str()).clicked() { + chosen = Some(i); + } + } + }); + if let Some(row) = chosen.and_then(|i| matches(&rows, &query).nth(i)) { + pick(d, row); + } + ui.add_space(4.0); + let shown = rows.iter().find(|r| picked(d, r)).map_or_else( + || { + let command = d.str("command"); + if command.is_empty() { tl!("None").to_string() } else { command } + }, + |r| r.shown.clone(), + ); + widgets::label_row(ui, "Menu Item:", LABEL_W, |ui| { + ui.label(shown); + }); + ui.label(egui::RichText::new(tl!("Items that open a dialog, and File, Window and Help menu items, are not listed.")).color(dim)); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = json!({"command": d.str("command"), "params": d.fields.get("params").cloned().unwrap_or_else(|| json!({}))}); + for key in ["set", "action", "step"] { + if let Some(value) = d.fields.get(key) { + params[key] = value.clone(); + } + } + let r = app.run(crate::panels::actions::INSERT_MENU_ITEM, params)?; + app.ui.dialog = None; + Ok(r) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::Lang; + + #[test] + fn find_picks_the_one_match_and_notes_none_or_several() { + let rows = build(Lang::EN, true); + let mut d = Dialog::new(KIND, json!({"find": "object group", "command": "", "params": {}, "note": ""})); + find(&rows, &mut d); + assert_eq!((d.str("note"), d.str("command")), ("several".to_string(), String::new()), "Group and Ungroup both match"); + d.fields.insert("find".into(), json!("OBJECT.UNGROUP")); + find(&rows, &mut d); + assert_eq!((d.str("command"), d.fields["params"].clone(), d.str("note")), ("object.ungroup".to_string(), json!({}), String::new())); + d.fields.insert("find".into(), json!("no such menu item here")); + find(&rows, &mut d); + assert_eq!((d.str("note"), d.str("command")), ("none".to_string(), "object.ungroup".to_string()), "a failed Find keeps the pick"); + } + + /// The list holds the menu items that run an engine command without asking for values first, + /// outside the application, File, Window and Help menus, and leaves out Undo, Redo, the Pastes, + /// Object › Align, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern and the + /// saved-selection, saved-view and recent-font items. + #[test] + fn the_list_holds_only_menu_items_that_can_be_steps() { + let items = menus::insertable_items(true); + assert!(items.len() > 100, "only {} items", items.len()); + for item in &items { + assert!(vectorcraft_engine::find_command(item.command).is_some(), "{item:?}"); + assert!(!item.label.ends_with('…'), "{item:?}"); + assert!(!["VectorCraft", "File", "Window", "Help"].contains(&item.path[0]), "{item:?}"); + assert!(item.params.is_object(), "{item:?}"); + } + let has = |command: &str| items.iter().any(|i| i.command == command); + for command in ["object.group", "object.ungroup", "object.arrange.bringToFront"] { + assert!(has(command), "{command}"); + } + for command in [ + "edit.undo", + "edit.redo", + "edit.paste", + "edit.pasteInFront", + "object.move", + "object.align", + "imageTrace.make", + "imageTrace.makeAndExpand", + "object.pattern.make", + "object.pattern.edit", + "file.save", + "view.zoomIn", + "select.recall1", + ] { + assert!(!has(command), "{command}"); + } + } + + /// Rows show their menu path in the UI language, and Find matches the English path, the shown + /// one and the command id. + #[test] + fn rows_show_the_translated_path_and_match_english_shown_text_and_ids() { + let de = Lang::from_code("de").unwrap(); + let rows = build(de, true); + let group = rows.iter().find(|r| r.command == "object.group").unwrap(); + assert_eq!(group.shown, format!("{} › {}", crate::i18n::tr(de, "Object"), crate::i18n::tr(de, "Group"))); + for words in ["object group", group.shown.as_str(), "object.group"] { + assert!(matches(&rows, words).any(|r| r.command == "object.group"), "{words}"); + } + let shared = rows.iter().any(|r| rows.iter().any(|o| o.command == r.command && o.params != r.params)); + assert!(shared, "items that share a command are listed apart, by their params"); + } +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index febbc6488..3a58ee3a9 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -391,6 +391,12 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option vec![(tl!("Left"), "left"), (tl!("Right"), "right")], + ( + vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE, + "shadowPosition", + ) => { + vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")] + } (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] } diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 6ca2e5771..b0cac0c01 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -333,6 +333,27 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { store_selected(d, &selected); } }); + shadow_controls(ui, d); +} + +/// The Shadows switch, and below it while the switch is on or mixed, the shadow's Position, +/// Distance from Object and Shadow Bounds. The first light casts the shadow, so they are +/// unavailable while Shading is off. +fn shadow_controls(ui: &mut egui::Ui, d: &mut Dialog) { + ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { + toggle(ui, d, "shadow", tl!("Shadows"), false); + if flag(d, "shadow", false) == Some(false) { + return; + } + let positions: Vec<(Value, &str)> = super::effect::own_choices(&d.str("__effect"), "shadowPosition", false) + .unwrap_or_default() + .into_iter() + .map(|(label, value)| (json!(value), label)) + .collect(); + choice(ui, d, "shadowPosition", tl!("Position"), &positions); + number(ui, d, "shadowDistance", tl!("Distance from Object"), (0.0, 100.0), "%", 1, None); + number(ui, d, "shadowBounds", tl!("Shadow Bounds"), (10.0, 400.0), "%", 1, None); + }); } fn lighting_presets(ui: &mut egui::Ui, d: &mut Dialog) { diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index 103fbe6ad..ba11d8666 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -148,6 +148,24 @@ fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() assert!(!h.app.session.in_interaction()); } +#[test] +fn the_shadows_switch_shows_its_settings_and_turns_the_shadow_on_in_one_undo_step() { + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", EXTRUDE, vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.click(h.rect("Lighting").center()); + assert!(!h.texts.iter().any(|(s, _)| s == "Shadow Bounds"), "the settings show only while shadows are on"); + h.click(h.rect("Shadows").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields.get("shadow"), Some(&json!(true))); + assert!(h.texts.iter().any(|(s, _)| s == "Distance from Object")); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["shadow"], json!(true), "the preview draws the shadow"); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { let mut h = Harness::new(vec2(1280.0, 900.0)); @@ -158,7 +176,9 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label))); } let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap(); - assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact lighting controls with light management: {panel:?}"); + // The Shadows switch adds one 28-point row to the 570-point bound; its settings show only + // while it is on. + assert!(panel.width() < 420.0 && panel.height() < 600.0, "compact lighting controls with light management: {panel:?}"); h.drag(h.rect("Height").center(), vec2(24.0, 0.0)); assert_ne!(h.value("lightHeight"), 30.0); h.click(h.response(("revolve-reset", "lightHeight")).center()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1c73b66ad..f135ae7e7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5521,6 +5521,11 @@ theme Appearance Външен вид Softness Мекота Ambient Light Околна светлина Light behind the object Светлина зад обекта + Shadows Сенки + Distance from Object Разстояние от обекта + Shadow Bounds Граници на сянката + Behind Object Зад обекта + Below Object Под обекта 3D Lighting Preset Предварителна настройка за 3D осветление Standard Стандартно Diffuse Дифузно @@ -5898,6 +5903,16 @@ axis Y Axis Y ос @msg message must be text message трябва да бъде текст @msg allowContinue must be true or false allowContinue трябва да бъде true или false @msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране + Insert Menu Item… Вмъкване на елемент от меню… + Insert Menu Item Вмъкване на елемент от меню + Menu Item: Елемент от меню: + No menu item matches. Няма съвпадащ елемент от меню. + Several menu items match. Choose one from the list. Съвпадат няколко елемента от меню. Изберете един от списъка. + Items that open a dialog, and File, Window and Help menu items, are not listed. Елементите, които отварят диалогов прозорец, и елементите от менютата „Файл“, „Прозорец“ и „Помощ“ не са изброени. +@msg command must be text command трябва да бъде текст +@msg no menu item that can be inserted runs this command няма елемент от меню за вмъкване, който изпълнява тази команда +@msg the action already has 10,000 steps действието вече има 10 000 стъпки +@msg the action set would be larger than 8 MiB наборът от действия ще стане по-голям от 8 MiB Playback Options Опции за възпроизвеждане Accelerated Ускорено Step by Step Стъпка по стъпка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 7d7f3613d..67ff72c98 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5605,6 +5605,11 @@ mark-style Japanese Japanski Softness Mekoća Ambient Light Ambijentalno svjetlo Light behind the object Svjetlo iza objekta + Shadows Sjene + Distance from Object Udaljenost od objekta + Shadow Bounds Granice sjene + Behind Object Iza objekta + Below Object Ispod objekta 3D Lighting Preset Predložak 3D osvjetljenja Standard Standardno Diffuse Difuzno @@ -5937,6 +5942,16 @@ axis Y Axis Y osa @msg message must be text message mora biti tekst @msg allowContinue must be true or false allowContinue mora biti true ili false @msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem + Insert Menu Item… Umetni stavku menija… + Insert Menu Item Umetni stavku menija + Menu Item: Stavka menija: + No menu item matches. Nijedna stavka menija ne odgovara. + Several menu items match. Choose one from the list. Odgovara više stavki menija. Izaberite jednu sa liste. + Items that open a dialog, and File, Window and Help menu items, are not listed. Stavke koje otvaraju dijalog i stavke menija Datoteka, Prozor i Pomoć nisu navedene. +@msg command must be text command mora biti tekst +@msg no menu item that can be inserted runs this command nijedna stavka menija koja se može umetnuti ne pokreće ovu naredbu +@msg the action already has 10,000 steps akcija već ima 10.000 koraka +@msg the action set would be larger than 8 MiB skup akcija bio bi veći od 8 MiB Playback Options Opcije reprodukcije Accelerated Ubrzano Step by Step Korak po korak diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 55153a4bb..b77d8cffc 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2266,6 +2266,11 @@ digits Malayalam Malaiàlam Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat. Light Clar Light behind the object Llum darrere de l’objecte + Shadows Ombres + Distance from Object Distància de l’objecte + Shadow Bounds Límits de l’ombra + Behind Object Darrere de l’objecte + Below Object Sota l’objecte Light Blue Blau clar Light Red Vermell clar Light Direction Direcció de la llum @@ -3942,6 +3947,16 @@ digits Malayalam Malaiàlam @msg message must be text message ha de ser text @msg allowContinue must be true or false allowContinue ha de ser true o false @msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada + Insert Menu Item… Insereix un element de menú… + Insert Menu Item Insereix un element de menú + Menu Item: Element de menú: + No menu item matches. Cap element de menú no coincideix. + Several menu items match. Choose one from the list. Coincideixen diversos elements de menú. Trieu-ne un de la llista. + Items that open a dialog, and File, Window and Help menu items, are not listed. No es mostren els elements que obren un quadre de diàleg ni els elements dels menús Fitxer, Finestra i Ajuda. +@msg command must be text command ha de ser text +@msg no menu item that can be inserted runs this command cap element de menú que es pugui inserir no executa aquesta ordre +@msg the action already has 10,000 steps l’acció ja té 10.000 passos +@msg the action set would be larger than 8 MiB el conjunt d’accions superaria els 8 MiB Playback Options Opcions de reproducció Accelerated Accelerada Step by Step Pas a pas diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2c15b9166..6ab6f99f8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5736,6 +5736,11 @@ mark-style Japanese Japonské Softness Měkkost Ambient Light Okolní světlo Light behind the object Světlo za objektem + Shadows Stíny + Distance from Object Vzdálenost od objektu + Shadow Bounds Hranice stínu + Behind Object Za objektem + Below Object Pod objektem 3D Lighting Preset Předvolba 3D osvětlení Standard Standardní Diffuse Rozptýlené @@ -6127,6 +6132,16 @@ axis Y Axis osa Y @msg message must be text message musí být text @msg allowContinue must be true or false allowContinue musí být true nebo false @msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením + Insert Menu Item… Vložit položku nabídky… + Insert Menu Item Vložit položku nabídky + Menu Item: Položka nabídky: + No menu item matches. Žádná položka nabídky neodpovídá. + Several menu items match. Choose one from the list. Odpovídá několik položek nabídky. Vyberte jednu ze seznamu. + Items that open a dialog, and File, Window and Help menu items, are not listed. Položky, které otevírají dialogové okno, a položky nabídek Soubor, Okno a Nápověda nejsou uvedeny. +@msg command must be text command musí být text +@msg no menu item that can be inserted runs this command tento příkaz nespouští žádná položka nabídky, kterou lze vložit +@msg the action already has 10,000 steps akce už má 10 000 kroků +@msg the action set would be larger than 8 MiB sada akcí by byla větší než 8 MiB Playback Options Možnosti přehrávání Accelerated Zrychleně Step by Step Krok za krokem diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e838c2a99..563c7f080 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5257,6 +5257,11 @@ mark-style Japanese Japansk Softness Blødhed Ambient Light Omgivelseslys Light behind the object Lys bag objektet + Shadows Skygger + Distance from Object Afstand fra objektet + Shadow Bounds Skyggegrænser + Behind Object Bag objektet + Below Object Under objektet 3D Lighting Preset Forudindstilling for 3D-belysning Standard Standard Diffuse Diffust @@ -5913,6 +5918,16 @@ axis Y Axis Y-akse @msg message must be text message skal være tekst @msg allowContinue must be true or false allowContinue skal være true eller false @msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop + Insert Menu Item… Indsæt menupunkt… + Insert Menu Item Indsæt menupunkt + Menu Item: Menupunkt: + No menu item matches. Intet menupunkt passer. + Several menu items match. Choose one from the list. Flere menupunkter passer. Vælg et fra listen. + Items that open a dialog, and File, Window and Help menu items, are not listed. Punkter, der åbner en dialogboks, og punkter i menuerne Filer, Vindue og Hjælp vises ikke. +@msg command must be text command skal være tekst +@msg no menu item that can be inserted runs this command intet menupunkt, der kan indsættes, kører denne kommando +@msg the action already has 10,000 steps handlingen har allerede 10.000 trin +@msg the action set would be larger than 8 MiB handlingssættet ville blive større end 8 MiB Playback Options Afspilningsindstillinger Accelerated Accelereret Step by Step Trin for trin diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2217195e4..d1bb87a7c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5311,6 +5311,11 @@ effect Soft Weich Softness Weichheit Ambient Light Umgebungslicht Light behind the object Licht hinter dem Objekt + Shadows Schatten + Distance from Object Abstand vom Objekt + Shadow Bounds Schattenbegrenzung + Behind Object Hinter dem Objekt + Below Object Unter dem Objekt 3D Lighting Preset 3D-Beleuchtungsvorgabe Standard Standard Diffuse Diffus @@ -5710,6 +5715,16 @@ axis Y Axis Y-Achse @msg message must be text message muss Text sein @msg allowContinue must be true or false allowContinue muss true oder false sein @msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen + Insert Menu Item… Menübefehl einfügen… + Insert Menu Item Menübefehl einfügen + Menu Item: Menübefehl: + No menu item matches. Kein Menübefehl passt. + Several menu items match. Choose one from the list. Mehrere Menübefehle passen. Wählen Sie einen aus der Liste. + Items that open a dialog, and File, Window and Help menu items, are not listed. Befehle, die ein Dialogfeld öffnen, sowie Befehle der Menüs „Datei“, „Fenster“ und „Hilfe“ werden nicht aufgeführt. +@msg command must be text command muss Text sein +@msg no menu item that can be inserted runs this command kein einfügbarer Menübefehl führt diesen Befehl aus +@msg the action already has 10,000 steps die Aktion hat bereits 10.000 Schritte +@msg the action set would be larger than 8 MiB der Aktionssatz wäre größer als 8 MiB Playback Options Wiedergabeoptionen Accelerated Beschleunigt Step by Step Schrittweise diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b979b1a03..dde1e2952 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5415,6 +5415,11 @@ digits Malayalam Μαλαγιάλαμ to set spacing and orientation για ορισμό απόστασης και προσανατολισμού : : Light behind the object Φως πίσω από το αντικείμενο + Shadows Σκιές + Distance from Object Απόσταση από το αντικείμενο + Shadow Bounds Όρια σκιάς + Behind Object Πίσω από το αντικείμενο + Below Object Κάτω από το αντικείμενο 3D Lighting Preset Προεπιλογή φωτισμού 3D Standard Τυπικός Diffuse Διάχυτος @@ -6078,6 +6083,16 @@ axis Y Axis Άξονας Y @msg message must be text Το message πρέπει να είναι κείμενο @msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false @msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή + Insert Menu Item… Εισαγωγή στοιχείου μενού… + Insert Menu Item Εισαγωγή στοιχείου μενού + Menu Item: Στοιχείο μενού: + No menu item matches. Κανένα στοιχείο μενού δεν ταιριάζει. + Several menu items match. Choose one from the list. Ταιριάζουν πολλά στοιχεία μενού. Επιλέξτε ένα από τη λίστα. + Items that open a dialog, and File, Window and Help menu items, are not listed. Τα στοιχεία που ανοίγουν παράθυρο διαλόγου και τα στοιχεία των μενού «Αρχείο», «Παράθυρο» και «Βοήθεια» δεν εμφανίζονται. +@msg command must be text Το command πρέπει να είναι κείμενο +@msg no menu item that can be inserted runs this command κανένα στοιχείο μενού που μπορεί να εισαχθεί δεν εκτελεί αυτή την εντολή +@msg the action already has 10,000 steps η ενέργεια έχει ήδη 10.000 βήματα +@msg the action set would be larger than 8 MiB το σύνολο ενεργειών θα ξεπερνούσε τα 8 MiB Playback Options Επιλογές αναπαραγωγής Accelerated Επιταχυμένα Step by Step Βήμα προς βήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bba6027fc..407a4d9d4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5677,6 +5677,11 @@ mark-style Japanese Japonesas Softness Suavidad Ambient Light Luz ambiental Light behind the object Luz detrás del objeto + Shadows Sombras + Distance from Object Distancia del objeto + Shadow Bounds Límites de la sombra + Behind Object Detrás del objeto + Below Object Debajo del objeto 3D Lighting Preset Ajuste preestablecido de iluminación 3D Standard Estándar Diffuse Difusa @@ -6069,6 +6074,16 @@ axis Y Axis Eje Y @msg message must be text message debe ser texto @msg allowContinue must be true or false allowContinue debe ser true o false @msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada + Insert Menu Item… Insertar elemento de menú… + Insert Menu Item Insertar elemento de menú + Menu Item: Elemento de menú: + No menu item matches. Ningún elemento de menú coincide. + Several menu items match. Choose one from the list. Coinciden varios elementos de menú. Elija uno de la lista. + Items that open a dialog, and File, Window and Help menu items, are not listed. No se muestran los elementos que abren un cuadro de diálogo ni los de los menús Archivo, Ventana y Ayuda. +@msg command must be text command debe ser texto +@msg no menu item that can be inserted runs this command ningún elemento de menú que se pueda insertar ejecuta este comando +@msg the action already has 10,000 steps la acción ya tiene 10 000 pasos +@msg the action set would be larger than 8 MiB el conjunto de acciones superaría los 8 MiB Playback Options Opciones de reproducción Accelerated Acelerada Step by Step Paso a paso diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c406008a2..8b9eb2539 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5618,6 +5618,11 @@ mark-style Japanese Japanilainen Softness Pehmeys Ambient Light Ympäristövalo Light behind the object Valo objektin takana + Shadows Varjot + Distance from Object Etäisyys objektista + Shadow Bounds Varjon rajat + Behind Object Objektin takana + Below Object Objektin alla 3D Lighting Preset 3D-valaistuksen esiasetus Standard Vakio Diffuse Hajavalo @@ -6157,6 +6162,16 @@ axis Y Axis Y-akseli @msg message must be text message on oltava tekstiä @msg allowContinue must be true or false allowContinue on oltava true tai false @msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys + Insert Menu Item… Lisää valikkokohta… + Insert Menu Item Lisää valikkokohta + Menu Item: Valikkokohta: + No menu item matches. Mikään valikkokohta ei vastaa hakua. + Several menu items match. Choose one from the list. Useampi valikkokohta vastaa hakua. Valitse yksi luettelosta. + Items that open a dialog, and File, Window and Help menu items, are not listed. Valintaikkunan avaavia kohtia sekä Tiedosto-, Ikkuna- ja Ohje-valikkojen kohtia ei luetella. +@msg command must be text command on oltava tekstiä +@msg no menu item that can be inserted runs this command mikään lisättävissä oleva valikkokohta ei suorita tätä komentoa +@msg the action already has 10,000 steps toiminnossa on jo 10 000 vaihetta +@msg the action set would be larger than 8 MiB toimintosarja olisi suurempi kuin 8 MiB Playback Options Toistoasetukset Accelerated Nopeutettu Step by Step Vaihe kerrallaan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 473fbb552..dafc9e19a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5732,6 +5732,11 @@ theme Appearance Apparence Softness Douceur Ambient Light Lumière ambiante Light behind the object Lumière derrière l'objet + Shadows Ombres + Distance from Object Distance de l'objet + Shadow Bounds Limites de l'ombre + Behind Object Derrière l'objet + Below Object Sous l'objet 3D Lighting Preset Préréglage d’éclairage 3D Standard Standard Diffuse Diffus @@ -6122,6 +6127,16 @@ axis Y Axis Axe Y @msg message must be text message doit être du texte @msg allowContinue must be true or false allowContinue doit valoir true ou false @msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt + Insert Menu Item… Insérer une commande de menu… + Insert Menu Item Insérer une commande de menu + Menu Item: Commande de menu : + No menu item matches. Aucune commande de menu ne correspond. + Several menu items match. Choose one from the list. Plusieurs commandes de menu correspondent. Choisissez-en une dans la liste. + Items that open a dialog, and File, Window and Help menu items, are not listed. Les commandes qui ouvrent une boîte de dialogue et celles des menus Fichier, Fenêtre et Aide ne sont pas listées. +@msg command must be text command doit être du texte +@msg no menu item that can be inserted runs this command aucune commande de menu insérable n’exécute cette commande +@msg the action already has 10,000 steps le script compte déjà 10 000 étapes +@msg the action set would be larger than 8 MiB le jeu de scripts dépasserait 8 Mio Playback Options Options d’exécution Accelerated Accélérée Step by Step Pas à pas diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index fc011cfdb..78a855b90 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2485,6 +2485,11 @@ antiAlias Sharp शार्प Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं। Light लाइट Light behind the object ऑब्जेक्ट के पीछे प्रकाश + Shadows छायाएँ + Distance from Object ऑब्जेक्ट से दूरी + Shadow Bounds छाया सीमाएँ + Behind Object ऑब्जेक्ट के पीछे + Below Object ऑब्जेक्ट के नीचे Light Blue हल्का नीला Light Direction लाइट की दिशा Light Gray हल्का ग्रे @@ -3391,6 +3396,16 @@ antiAlias Sharp शार्प @msg message must be text message टेक्स्ट होना चाहिए @msg allowContinue must be true or false allowContinue true या false होना चाहिए @msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता + Insert Menu Item… मेनू आइटम जोड़ें… + Insert Menu Item मेनू आइटम जोड़ें + Menu Item: मेनू आइटम: + No menu item matches. कोई मेनू आइटम मेल नहीं खाता। + Several menu items match. Choose one from the list. कई मेनू आइटम मेल खाते हैं। सूची से एक चुनें। + Items that open a dialog, and File, Window and Help menu items, are not listed. डायलॉग खोलने वाले आइटम और फ़ाइल, विंडो और सहायता मेनू के आइटम सूचीबद्ध नहीं हैं। +@msg command must be text command टेक्स्ट होना चाहिए +@msg no menu item that can be inserted runs this command जोड़ा जा सकने वाला कोई मेनू आइटम यह कमांड नहीं चलाता +@msg the action already has 10,000 steps एक्शन में पहले से 10,000 कदम हैं +@msg the action set would be larger than 8 MiB एक्शन सेट 8 MiB से बड़ा हो जाएगा Playback Options प्लेबैक ऑप्शन्स Playback Options… प्लेबैक ऑप्शन्स… Plug-in Info प्लगइन जानकारी diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4a341ff7b..d93769d5f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5719,6 +5719,11 @@ imageTrace Outline Körvonal Softness Lágyság Ambient Light Környezeti fény Light behind the object Fény az objektum mögött + Shadows Árnyékok + Distance from Object Távolság az objektumtól + Shadow Bounds Árnyék határai + Behind Object Az objektum mögött + Below Object Az objektum alatt 3D Lighting Preset 3D világítási készlet Standard Normál Diffuse Szórt @@ -6110,6 +6115,16 @@ axis Y Axis Y tengely @msg message must be text message: szöveg kell @msg allowContinue must be true or false allowContinue: true vagy false kell @msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet + Insert Menu Item… Menüelem beszúrása… + Insert Menu Item Menüelem beszúrása + Menu Item: Menüelem: + No menu item matches. Egy menüelem sem egyezik. + Several menu items match. Choose one from the list. Több menüelem is egyezik. Válasszon egyet a listából. + Items that open a dialog, and File, Window and Help menu items, are not listed. A párbeszédablakot megnyitó elemek, valamint a Fájl, az Ablak és a Súgó menü elemei nem szerepelnek a listában. +@msg command must be text command: szöveg kell +@msg no menu item that can be inserted runs this command egyetlen beszúrható menüelem sem futtatja ezt a parancsot +@msg the action already has 10,000 steps a műveletnek már 10 000 lépése van +@msg the action set would be larger than 8 MiB a műveletkészlet nagyobb lenne 8 MiB-nál Playback Options Lejátszási beállítások Accelerated Gyorsított Step by Step Lépésenként diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1ffa66568..b67c2a3fb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5257,6 +5257,11 @@ mark-style Japanese Jepang Softness Kelembutan Ambient Light Cahaya ambient Light behind the object Cahaya di belakang objek + Shadows Bayangan + Distance from Object Jarak dari objek + Shadow Bounds Batas bayangan + Behind Object Di belakang objek + Below Object Di bawah objek 3D Lighting Preset Preset Pencahayaan 3D Standard Standar Diffuse Difus @@ -5913,6 +5918,16 @@ axis Y Axis Sumbu Y @msg message must be text message harus berupa teks @msg allowContinue must be true or false allowContinue harus true atau false @msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti + Insert Menu Item… Sisipkan item menu… + Insert Menu Item Sisipkan item menu + Menu Item: Item menu: + No menu item matches. Tidak ada item menu yang cocok. + Several menu items match. Choose one from the list. Beberapa item menu cocok. Pilih salah satu dari daftar. + Items that open a dialog, and File, Window and Help menu items, are not listed. Item yang membuka dialog, serta item menu Berkas, Jendela, dan Bantuan, tidak dicantumkan. +@msg command must be text command harus berupa teks +@msg no menu item that can be inserted runs this command tidak ada item menu yang dapat disisipkan yang menjalankan perintah ini +@msg the action already has 10,000 steps tindakan sudah memiliki 10.000 langkah +@msg the action set would be larger than 8 MiB set tindakan akan lebih besar dari 8 MiB Playback Options Opsi pemutaran Accelerated Dipercepat Step by Step Langkah demi langkah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e100ea60c..6a84dac86 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5346,6 +5346,11 @@ lighting Light Luce Softness Morbidezza Ambient Light Luce ambientale Light behind the object Luce dietro l'oggetto + Shadows Ombre + Distance from Object Distanza dall'oggetto + Shadow Bounds Limiti dell'ombra + Behind Object Dietro l'oggetto + Below Object Sotto l'oggetto 3D Lighting Preset Predefinito illuminazione 3D Standard Standard Diffuse Diffusa @@ -5744,6 +5749,16 @@ axis Y Axis Asse Y @msg message must be text message deve essere testo @msg allowContinue must be true or false allowContinue deve essere true o false @msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione + Insert Menu Item… Inserisci voce di menu… + Insert Menu Item Inserisci voce di menu + Menu Item: Voce di menu: + No menu item matches. Nessuna voce di menu corrisponde. + Several menu items match. Choose one from the list. Corrispondono più voci di menu. Sceglierne una dall’elenco. + Items that open a dialog, and File, Window and Help menu items, are not listed. Le voci che aprono una finestra di dialogo e le voci dei menu File, Finestra e Aiuto non sono elencate. +@msg command must be text command deve essere testo +@msg no menu item that can be inserted runs this command nessuna voce di menu inseribile esegue questo comando +@msg the action already has 10,000 steps l’azione ha già 10.000 passaggi +@msg the action set would be larger than 8 MiB il set di azioni supererebbe 8 MiB Playback Options Opzioni di riproduzione Accelerated Accelerata Step by Step Passo per passo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 27d4cd146..301dcd911 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5388,6 +5388,11 @@ lighting Light ライト Softness 柔らかさ Ambient Light 環境光 Light behind the object オブジェクトの背後のライト + Shadows シャドウ + Distance from Object オブジェクトからの距離 + Shadow Bounds シャドウの範囲 + Behind Object オブジェクトの背後 + Below Object オブジェクトの下 3D Lighting Preset 3D ライティングプリセット Standard 標準 Diffuse 拡散 @@ -5809,6 +5814,16 @@ axis Y Axis Y 軸 @msg message must be text message にはテキストを指定してください @msg allowContinue must be true or false allowContinue には true または false を指定してください @msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません + Insert Menu Item… メニュー項目を挿入… + Insert Menu Item メニュー項目を挿入 + Menu Item: メニュー項目: + No menu item matches. 一致するメニュー項目がありません。 + Several menu items match. Choose one from the list. 複数のメニュー項目が一致します。リストから 1 つ選択してください。 + Items that open a dialog, and File, Window and Help menu items, are not listed. ダイアログを開く項目と、ファイル、ウィンドウ、ヘルプメニューの項目は表示されません。 +@msg command must be text command にはテキストを指定してください +@msg no menu item that can be inserted runs this command このコマンドを実行する、挿入できるメニュー項目はありません +@msg the action already has 10,000 steps アクションにはすでに 10,000 ステップがあります +@msg the action set would be larger than 8 MiB アクションセットが 8 MiB を超えます Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index af96b47db..428721f87 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5298,6 +5298,11 @@ imageTrace Outline კონტური In the File ფაილში Keys კლავიშები Light behind the object სინათლე ობიექტის უკან + Shadows ჩრდილები + Distance from Object მანძილი ობიექტიდან + Shadow Bounds ჩრდილის საზღვრები + Behind Object ობიექტის უკან + Below Object ობიექტის ქვეშ 3D Lighting Preset 3D განათების წინასწარი პარამეტრი Standard სტანდარტული Diffuse დიფუზური @@ -6078,6 +6083,16 @@ axis Y Axis Y ღერძი @msg message must be text message უნდა იყოს ტექსტი @msg allowContinue must be true or false allowContinue უნდა იყოს true ან false @msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება + Insert Menu Item… მენიუს ელემენტის ჩასმა… + Insert Menu Item მენიუს ელემენტის ჩასმა + Menu Item: მენიუს ელემენტი: + No menu item matches. მენიუს არცერთი ელემენტი არ ემთხვევა. + Several menu items match. Choose one from the list. ემთხვევა მენიუს რამდენიმე ელემენტი. აირჩიეთ ერთი სიიდან. + Items that open a dialog, and File, Window and Help menu items, are not listed. დიალოგის გამხსნელი ელემენტები და მენიუების „ფაილი“, „ფანჯარა“ და „დახმარება“ ელემენტები სიაში არ არის. +@msg command must be text command უნდა იყოს ტექსტი +@msg no menu item that can be inserted runs this command ჩასმადი მენიუს არცერთი ელემენტი არ უშვებს ამ ბრძანებას +@msg the action already has 10,000 steps მოქმედებას უკვე 10 000 ნაბიჯი აქვს +@msg the action set would be larger than 8 MiB მოქმედებების ნაკრები 8 MiB-ზე დიდი გახდებოდა Playback Options შესრულების პარამეტრები Accelerated დაჩქარებული Step by Step ნაბიჯ-ნაბიჯ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 26a71502a..f9819dbe4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5307,6 +5307,11 @@ lighting Light 광원 Softness 부드러움 Ambient Light 주변광 Light behind the object 개체 뒤의 조명 + Shadows 그림자 + Distance from Object 개체로부터의 거리 + Shadow Bounds 그림자 범위 + Behind Object 개체 뒤 + Below Object 개체 아래 3D Lighting Preset 3D 조명 사전 설정 Standard 표준 Diffuse 확산 @@ -5705,6 +5710,16 @@ axis Y Axis Y축 @msg message must be text message의 값은 텍스트여야 합니다 @msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다 @msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 + Insert Menu Item… 메뉴 항목 삽입… + Insert Menu Item 메뉴 항목 삽입 + Menu Item: 메뉴 항목: + No menu item matches. 일치하는 메뉴 항목이 없습니다. + Several menu items match. Choose one from the list. 여러 메뉴 항목이 일치합니다. 목록에서 하나를 선택하세요. + Items that open a dialog, and File, Window and Help menu items, are not listed. 대화 상자를 여는 항목과 파일, 윈도우, 도움말 메뉴의 항목은 표시되지 않습니다. +@msg command must be text command의 값은 텍스트여야 합니다 +@msg no menu item that can be inserted runs this command 이 명령을 실행하는 삽입 가능한 메뉴 항목이 없습니다 +@msg the action already has 10,000 steps 액션에 이미 단계가 10,000개 있습니다 +@msg the action set would be larger than 8 MiB 액션 세트가 8 MiB를 넘게 됩니다 Playback Options 재생 옵션 Accelerated 가속 Step by Step 단계별 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0e954626a..19caa5058 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5269,6 +5269,11 @@ mark-style Japanese Japans Softness Zachtheid Ambient Light Omgevingslicht Light behind the object Licht achter het object + Shadows Schaduwen + Distance from Object Afstand tot object + Shadow Bounds Schaduwgrenzen + Behind Object Achter object + Below Object Onder object 3D Lighting Preset Voorinstelling 3D-belichting Standard Standaard Diffuse Diffuus @@ -5964,6 +5969,16 @@ axis Y Axis Y-as @msg message must be text message moet tekst zijn @msg allowContinue must be true or false allowContinue moet true of false zijn @msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen + Insert Menu Item… Menu-item invoegen… + Insert Menu Item Menu-item invoegen + Menu Item: Menu-item: + No menu item matches. Geen menu-item komt overeen. + Several menu items match. Choose one from the list. Meerdere menu-items komen overeen. Kies er een uit de lijst. + Items that open a dialog, and File, Window and Help menu items, are not listed. Items die een dialoogvenster openen en items uit de menu’s Bestand, Venster en Help worden niet weergegeven. +@msg command must be text command moet tekst zijn +@msg no menu item that can be inserted runs this command geen invoegbaar menu-item voert deze opdracht uit +@msg the action already has 10,000 steps de actie heeft al 10.000 stappen +@msg the action set would be larger than 8 MiB de actieset zou groter worden dan 8 MiB Playback Options Afspeelopties Accelerated Versneld Step by Step Stap voor stap diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4692e7e24..4f1d76fe0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5575,6 +5575,11 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + Shadows Cienie + Distance from Object Odległość od obiektu + Shadow Bounds Granice cienia + Behind Object Za obiektem + Below Object Pod obiektem 3D Lighting Preset Ustawienie oświetlenia 3D Standard Standardowe Diffuse Rozproszone @@ -5981,6 +5986,16 @@ axis Y Axis Oś Y @msg message must be text message musi być tekstem @msg allowContinue must be true or false allowContinue musi mieć wartość true lub false @msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem + Insert Menu Item… Wstaw polecenie menu… + Insert Menu Item Wstaw polecenie menu + Menu Item: Polecenie menu: + No menu item matches. Żadne polecenie menu nie pasuje. + Several menu items match. Choose one from the list. Pasuje kilka poleceń menu. Wybierz jedno z listy. + Items that open a dialog, and File, Window and Help menu items, are not listed. Polecenia otwierające okno dialogowe oraz polecenia menu Plik, Okno i Pomoc nie są wyświetlane. +@msg command must be text command musi być tekstem +@msg no menu item that can be inserted runs this command żadne polecenie menu, które można wstawić, nie uruchamia tego polecenia +@msg the action already has 10,000 steps operacja ma już 10 000 kroków +@msg the action set would be larger than 8 MiB zestaw operacji byłby większy niż 8 MiB Playback Options Opcje odtwarzania Accelerated Przyspieszone Step by Step Krok po kroku diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0756e4407..a0b08b6bb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4595,6 +4595,11 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + Shadows Sombras + Distance from Object Distância do objeto + Shadow Bounds Limites da sombra + Behind Object Atrás do objeto + Below Object Abaixo do objeto 3D Lighting Preset Predefinição de iluminação 3D Standard Padrão Diffuse Difusa @@ -6042,6 +6047,16 @@ axis Y Axis Eixo Y @msg message must be text message deve ser texto @msg allowContinue must be true or false allowContinue deve ser true ou false @msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada + Insert Menu Item… Inserir Item de Menu… + Insert Menu Item Inserir Item de Menu + Menu Item: Item de menu: + No menu item matches. Nenhum item de menu corresponde. + Several menu items match. Choose one from the list. Vários itens de menu correspondem. Escolha um da lista. + Items that open a dialog, and File, Window and Help menu items, are not listed. Itens que abrem uma caixa de diálogo e itens dos menus Arquivo, Janela e Ajuda não são listados. +@msg command must be text command deve ser texto +@msg no menu item that can be inserted runs this command nenhum item de menu que possa ser inserido executa este comando +@msg the action already has 10,000 steps a ação já tem 10.000 etapas +@msg the action set would be larger than 8 MiB o conjunto de ações ficaria maior que 8 MiB Playback Options Opções de Reprodução Accelerated Acelerada Step by Step Passo a Passo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 076907192..ea21d1ca1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5340,6 +5340,11 @@ lighting Light Свет Softness Мягкость Ambient Light Окружающий свет Light behind the object Свет позади объекта + Shadows Тени + Distance from Object Расстояние от объекта + Shadow Bounds Границы тени + Behind Object Позади объекта + Below Object Под объектом 3D Lighting Preset Предустановка 3D освещения Standard Стандартное Diffuse Рассеянное @@ -5738,6 +5743,16 @@ axis Y Axis Ось Y @msg message must be text message должно быть текстом @msg allowContinue must be true or false allowContinue должно быть true или false @msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой + Insert Menu Item… Вставить пункт меню… + Insert Menu Item Вставить пункт меню + Menu Item: Пункт меню: + No menu item matches. Нет подходящих пунктов меню. + Several menu items match. Choose one from the list. Подходит несколько пунктов меню. Выберите один из списка. + Items that open a dialog, and File, Window and Help menu items, are not listed. Пункты, открывающие диалоговое окно, и пункты меню «Файл», «Окно» и «Справка» не показаны. +@msg command must be text command должно быть текстом +@msg no menu item that can be inserted runs this command ни один пункт меню, который можно вставить, не выполняет эту команду +@msg the action already has 10,000 steps в действии уже 10 000 шагов +@msg the action set would be larger than 8 MiB набор действий стал бы больше 8 MiB Playback Options Параметры воспроизведения Accelerated Ускоренно Step by Step Пошагово diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3958421d1..917c54835 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5634,6 +5634,11 @@ theme Appearance Utseende Softness Mjukhet Ambient Light Omgivande ljus Light behind the object Ljus bakom objektet + Shadows Skuggor + Distance from Object Avstånd från objektet + Shadow Bounds Skuggans gränser + Behind Object Bakom objektet + Below Object Under objektet 3D Lighting Preset Förinställning för 3D-belysning Standard Standard Diffuse Diffust @@ -6146,6 +6151,16 @@ axis Y Axis Y-axel @msg message must be text message måste vara text @msg allowContinue must be true or false allowContinue måste vara true eller false @msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp + Insert Menu Item… Infoga menyalternativ… + Insert Menu Item Infoga menyalternativ + Menu Item: Menyalternativ: + No menu item matches. Inget menyalternativ matchar. + Several menu items match. Choose one from the list. Flera menyalternativ matchar. Välj ett i listan. + Items that open a dialog, and File, Window and Help menu items, are not listed. Alternativ som öppnar en dialogruta och alternativ i menyerna Fil, Fönster och Hjälp visas inte. +@msg command must be text command måste vara text +@msg no menu item that can be inserted runs this command inget menyalternativ som kan infogas kör det här kommandot +@msg the action already has 10,000 steps åtgärden har redan 10 000 steg +@msg the action set would be larger than 8 MiB åtgärdsuppsättningen skulle bli större än 8 MiB Playback Options Uppspelningsalternativ Accelerated Accelererad Step by Step Steg för steg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f6bccd37a..ec20ae46c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5424,6 +5424,11 @@ theme Appearance Görünüm Softness Yumuşaklık Ambient Light Ortam Işığı Light behind the object Nesnenin arkasındaki ışık + Shadows Gölgeler + Distance from Object Nesneden uzaklık + Shadow Bounds Gölge sınırları + Behind Object Nesnenin arkasında + Below Object Nesnenin altında Registration: Kayıt: Dimension Boyut Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir. @@ -6083,6 +6088,16 @@ axis Y Axis Y ekseni @msg message must be text "message" metin olmalıdır @msg allowContinue must be true or false "allowContinue" true veya false olmalıdır @msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz + Insert Menu Item… Menü Öğesi Ekle… + Insert Menu Item Menü Öğesi Ekle + Menu Item: Menü öğesi: + No menu item matches. Eşleşen menü öğesi yok. + Several menu items match. Choose one from the list. Birden çok menü öğesi eşleşiyor. Listeden birini seçin. + Items that open a dialog, and File, Window and Help menu items, are not listed. İletişim kutusu açan öğeler ile Dosya, Pencere ve Yardım menülerinin öğeleri listelenmez. +@msg command must be text "command" metin olmalıdır +@msg no menu item that can be inserted runs this command eklenebilen hiçbir menü öğesi bu komutu çalıştırmaz +@msg the action already has 10,000 steps eylemde zaten 10.000 adım var +@msg the action set would be larger than 8 MiB eylem seti 8 MiB'tan büyük olurdu Playback Options Oynatma Seçenekleri Accelerated Hızlandırılmış Step by Step Adım Adım diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cf437f8cf..95a73dae7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5326,6 +5326,11 @@ lighting Light Світло Softness М'якість Ambient Light Навколишнє світло Light behind the object Світло позаду об'єкта + Shadows Тіні + Distance from Object Відстань від об'єкта + Shadow Bounds Межі тіні + Behind Object Позаду об'єкта + Below Object Під об'єктом 3D Lighting Preset Попереднє налаштування 3D освітлення Standard Стандартне Diffuse Розсіяне @@ -5724,6 +5729,16 @@ axis Y Axis Вісь Y @msg message must be text message має бути текстом @msg allowContinue must be true or false allowContinue має бути true або false @msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою + Insert Menu Item… Вставити пункт меню… + Insert Menu Item Вставити пункт меню + Menu Item: Пункт меню: + No menu item matches. Немає відповідних пунктів меню. + Several menu items match. Choose one from the list. Відповідає кілька пунктів меню. Виберіть один зі списку. + Items that open a dialog, and File, Window and Help menu items, are not listed. Пункти, що відкривають діалогове вікно, і пункти меню «Файл», «Вікно» та «Довідка» не показано. +@msg command must be text command має бути текстом +@msg no menu item that can be inserted runs this command жоден пункт меню, який можна вставити, не виконує цю команду +@msg the action already has 10,000 steps дія вже має 10 000 кроків +@msg the action set would be larger than 8 MiB набір дій став би більшим за 8 MiB Playback Options Параметри відтворення Accelerated Прискорено Step by Step Покроково diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 33940797d..4bbf09127 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5329,6 +5329,11 @@ theme Appearance Giao diện Softness Độ mềm Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng + Shadows Bóng đổ + Distance from Object Khoảng cách từ đối tượng + Shadow Bounds Giới hạn bóng + Behind Object Phía sau đối tượng + Below Object Bên dưới đối tượng 3D Lighting Preset Thiết lập sẵn ánh sáng 3D Standard Tiêu chuẩn Diffuse Khuếch tán @@ -5725,6 +5730,16 @@ axis Y Axis Trục Y @msg message must be text message phải là văn bản @msg allowContinue must be true or false allowContinue phải là true hoặc false @msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng + Insert Menu Item… Chèn mục menu… + Insert Menu Item Chèn mục menu + Menu Item: Mục menu: + No menu item matches. Không có mục menu nào khớp. + Several menu items match. Choose one from the list. Có nhiều mục menu khớp. Hãy chọn một mục trong danh sách. + Items that open a dialog, and File, Window and Help menu items, are not listed. Các mục mở hộp thoại và các mục trong menu Tệp, Cửa sổ và Trợ giúp không được liệt kê. +@msg command must be text command phải là văn bản +@msg no menu item that can be inserted runs this command không có mục menu nào có thể chèn chạy lệnh này +@msg the action already has 10,000 steps hành động đã có 10.000 bước +@msg the action set would be larger than 8 MiB bộ hành động sẽ lớn hơn 8 MiB Playback Options Tùy chọn phát lại Accelerated Tăng tốc Step by Step Từng bước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4de300550..d79d6e2ea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5608,6 +5608,11 @@ lighting Light 光源 Softness 柔和度 Ambient Light 环境光 Light behind the object 光源位于对象后方 + Shadows 阴影 + Distance from Object 与对象的距离 + Shadow Bounds 阴影边界 + Behind Object 对象后方 + Below Object 对象下方 3D Lighting Preset 3D 光照预设 Standard 标准 Diffuse 漫射 @@ -5965,6 +5970,16 @@ axis Y Axis Y 轴 @msg message must be text message 必须是文本 @msg allowContinue must be true or false allowContinue 必须是 true 或 false @msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 + Insert Menu Item… 插入菜单项… + Insert Menu Item 插入菜单项 + Menu Item: 菜单项: + No menu item matches. 没有匹配的菜单项。 + Several menu items match. Choose one from the list. 有多个菜单项匹配。请从列表中选择一个。 + Items that open a dialog, and File, Window and Help menu items, are not listed. 未列出会打开对话框的项目,以及“文件”“窗口”和“帮助”菜单中的项目。 +@msg command must be text command 必须是文本 +@msg no menu item that can be inserted runs this command 没有可插入的菜单项运行此命令 +@msg the action already has 10,000 steps 该动作已有 10,000 个步骤 +@msg the action set would be larger than 8 MiB 动作组将超过 8 MiB Playback Options 播放选项 Accelerated 加速 Step by Step 逐步 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e1ff303a3..78bfb3cef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5429,6 +5429,11 @@ lighting Light 光源 Softness 柔和度 Ambient Light 環境光 Light behind the object 光源位於物件後方 + Shadows 陰影 + Distance from Object 與物件的距離 + Shadow Bounds 陰影邊界 + Behind Object 物件後方 + Below Object 物件下方 3D Lighting Preset 3D 光照預設 Standard 標準 Diffuse 漫射 @@ -5762,6 +5767,16 @@ axis Y Axis Y 軸 @msg message must be text message 必須是文字 @msg allowContinue must be true or false allowContinue 必須是 true 或 false @msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 + Insert Menu Item… 插入選單項目… + Insert Menu Item 插入選單項目 + Menu Item: 選單項目: + No menu item matches. 沒有相符的選單項目。 + Several menu items match. Choose one from the list. 有多個選單項目相符。請從清單中選擇一個。 + Items that open a dialog, and File, Window and Help menu items, are not listed. 未列出會開啟對話框的項目,以及「檔案」、「視窗」和「說明」選單中的項目。 +@msg command must be text command 必須是文字 +@msg no menu item that can be inserted runs this command 沒有可插入的選單項目執行此指令 +@msg the action already has 10,000 steps 這個動作已有 10,000 個步驟 +@msg the action set would be larger than 8 MiB 動作組會超過 8 MiB Playback Options 播放選項 Accelerated 加速 Step by Step 逐步 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cc6f5d226..93abdfd31 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -80,6 +80,51 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// A file opened with a note about its page first and a note that its layers weren't read + /// second: the status bar names the layers note, counts both, and keeps both, the layers note + /// first, for its hover. A message about its linked files still replaces that message. Two + /// notes of what was left out keep the order the import gave them, and an open without notes + /// leaves the message and the notes an earlier open left. + #[test] + fn an_open_names_the_note_of_what_was_left_out_first() { + use vectorcraft_testkit::ai; + let (mut app, _) = app(); + let damaged = b"%AI12_CompressedData not zlib"; + let notes = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f"); + let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); + let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") }; + assert!(layers.contains("layers weren't read") && !page.contains("layers weren't read"), "{r}"); + assert_eq!(app.ui.status, opened_with_notes(2, layers)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![layers.clone(), page.clone()]))); + + // A message about its linked files replaces the open's message, as before. + app.session.prefs.update_links = "manually".into(); + let mut linked = r.clone(); + linked["modifiedLinks"] = json!([{ "ids": [1] }]); + opened(&mut app, &linked); + assert_eq!(app.ui.status, dialogs::missing_links::changed_on_disk_status(1)); + assert_eq!(app.ui.status_notes, Some((opened_with_notes(2, layers), vec![layers.clone(), page.clone()]))); + + let r = open_bytes(&mut app, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), None).unwrap(); + let both = notes(&r); + assert_eq!(both.len(), 2, "{r}"); + assert_eq!(app.ui.status, opened_with_notes(2, &both[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), both))); + + let r = open_document(&mut app, "page.pdf", &pdf, None, &json!({"editingData": false})).unwrap(); + let [only] = ¬es(&r)[..] else { panic!("one note: {r}") }; + assert_eq!(app.ui.status, opened_with_notes(1, only)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![only.clone()]))); + + let clean = br#""#; + open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); + let shown = (app.ui.status.clone(), app.ui.status_notes.clone()); + let r = open_bytes(&mut app, "clean.svg", clean, None).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 88375f3a8..3ca34cdd7 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -137,15 +137,30 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O } /// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says -/// what didn't come in as it was, and missing links are asked about. +/// what didn't come in as it was, and missing links are asked about. The status bar names the +/// first of the notes that say reading the file left something out (the ones that make Save ask +/// before writing over it), else the first note, and keeps the notes in that order for its hover. pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); - if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { - app.status(opened_with_notes(w.len(), w[0].as_str().unwrap_or_default())); + let notes = open_notes(app, r); + if let Some(first) = notes.first() { + app.status(opened_with_notes(notes.len(), first)); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); } crate::dialogs::missing_links::after_open(app, r); } +/// The notes of the open `r`, those of what reading the file left out (its document's +/// `import_losses`) first, each group in the order the import gave them. +fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec { + let notes: Vec = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); + let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default(); + let (mut first, rest): (Vec, Vec) = notes.into_iter().partition(|n| losses.contains(n)); + first.extend(rest); + first +} + /// The status bar's line for a document opened with `n` notes, the first of them `first`. pub(crate) fn opened_with_notes(n: usize, first: &str) -> String { crate::i18n::msg_n(n as u64, "Opened with {n} note: {first}", "Opened with {n} notes: {first}", &[("first", first)]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3d3f2a8c0..2b24124e1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -64,7 +64,9 @@ pub(crate) use bar::{home_key, home_showing}; #[cfg(test)] use bar::{menu_body, pointer_reaches_title}; pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items}; -pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut, shown_shortcut}; +pub use entries::{ + InsertableItem, MenuEntry, Row, context_entries, entry, insertable, insertable_items, menu_entries, pretty_shortcut, shown_shortcut, +}; pub(crate) use invoke::graph_dialog; #[cfg(test)] pub(crate) use invoke::with_held_keys; diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 815a00d6f..42ad7b655 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -127,6 +127,79 @@ pub fn context_entries(app: &VectorcraftApp) -> Vec { out } +/// A menu item that the Actions panel's Insert Menu Item… adds to an action as a step +/// ([`insertable_items`]). +#[derive(Clone, Debug, PartialEq)] +pub struct InsertableItem { + /// The menu title and the submenus that hold the item, as the tree names them in English. + pub path: Vec<&'static str>, + /// The item's label in the tree, in English. + pub label: &'static str, + pub command: &'static str, + /// The item's params; `{}` for an item that has none. + pub params: Value, +} + +/// The menus whose items are not added to actions: the application menu, File (documents and +/// files), Window (panels and workspaces) and Help. +const NOT_INSERTED_MENUS: [&str; 4] = ["VectorCraft", "File", "Window", "Help"]; + +/// Commands whose menu items do more than run the command, which action playback does not do: +/// Object › Image Trace › Make and Make and Expand trace off the UI thread with progress and Cancel +/// ([`crate::trace_runs::start`]), and Object › Pattern › Make and Edit Pattern open the Pattern +/// Options panel. +const MENU_ONLY_COMMANDS: [&str; 4] = ["imageTrace.make", "imageTrace.makeAndExpand", "object.pattern.make", "object.pattern.edit"]; + +/// Whether the item labeled `label` in menu `title`, which runs command `id` with `p`, can be a step +/// of an action. It can when it runs an engine command, which action playback runs as the menu +/// does; its label does not end with "…", the mark of an item that asks for values first; it is not +/// in a menu of [`NOT_INSERTED_MENUS`]; and it is not Undo, Redo, a Paste, an item that stands for a +/// saved selection, a saved view or a recent font, a plug-in's item or an action's own command. Nor +/// is it Object › Align, whose menu items add the Align To choice and the selection's key to their +/// params when clicked ([`crate::panels::align::with_align_to`]), which playback does not, or another +/// item of [`MENU_ONLY_COMMANDS`]. +pub fn insertable(title: &str, label: &str, id: &str, p: &Value) -> bool { + use super::slots::{RECENT_FONT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; + vectorcraft_engine::find_command(id).is_some() + && !label.ends_with('…') + && !NOT_INSERTED_MENUS.contains(&title) + && !matches!(id, "edit.undo" | "edit.redo" | "command.batch" | "object.align") + && !MENU_ONLY_COMMANDS.contains(&id) + && !id.starts_with("edit.paste") + && !id.starts_with("prefs.") + && !id.starts_with("action.") + && ![&RECENT_FONT_IDS[..], &SAVED_SELECTION_IDS[..], &SAVED_VIEW_IDS[..]].iter().any(|ids| ids.contains(&id)) + && !super::labels::lists_plugin(id, p) +} + +/// Every menu item that can be a step of an action ([`insertable`]), in menu order. An item that +/// shows in two places is listed in both. `english_names` is as [`menu_tree_named`] takes it. +pub fn insertable_items(english_names: bool) -> Vec { + fn walk(title: &'static str, path: &mut Vec<&'static str>, items: &[Item], out: &mut Vec) { + for it in items { + match it { + Item::Cmd(label, id, p) if insertable(title, label, id, p) => out.push(InsertableItem { + path: path.clone(), + label, + command: id, + params: if p.is_null() { serde_json::json!({}) } else { p.clone() }, + }), + Item::Sub(label, children) => { + path.push(label); + walk(title, path, children, out); + path.pop(); + } + _ => {} + } + } + } + let mut out = vec![]; + for (title, items) in menu_tree_named(english_names) { + walk(title, &mut vec![title], &items, &mut out); + } + out +} + fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8661ab806..3030ef2c6 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -919,6 +919,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::record(app, p), crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p), crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), + crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 90367f2fc..cc18554af 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1396,7 +1396,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::INSERT_STOP, "Insert Stop", "", - "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + crate::panels::actions::INSERT_MENU_ITEM, + "Insert Menu Item", + "", + "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), ( crate::panels::actions::CONTINUE_PLAYBACK, diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 2533ef029..de6b36bc6 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -870,31 +870,145 @@ const SELECTED_STEP: &str = "actions-selected-step"; /// them, it goes into the recording in progress, after the steps recorded so far. `message` is /// the text playback shows (default: none), and `allowContinue` offers Continue in the message box /// (default: false). An index past the last set, action or step, a param of another type, params -/// that are not an object, or no action and no recording is an error, and then nothing changes. -/// Record Stop's OK runs this. A stop in an action becomes the selected step, and its action and set -/// become the selected ones. → {set, action, step}, or {recording: true} +/// that are not an object, or no action and no recording is an error, and then nothing changes. So +/// is a stop that would give the action more than 10,000 steps, or its set a file larger than 8 MiB, +/// the most Load Actions… reads. Record Stop's OK runs this. A stop in an action becomes the selected +/// step, and its action and set become the selected ones. → {set, action, step}, or {recording: true} pub fn insert_stop(app: &mut VectorcraftApp, p: &Value) -> Result { if !p.is_object() { return Err("`params` must be an object".into()); } let (message, allow_continue) = stop_params(p)?; - let step = (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue})); + insert(app, p, (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue}))) +} + +/// The command that inserts a menu item's command as a step ([`insert_menu_item`]). +pub const INSERT_MENU_ITEM: &str = "ui.action.insertMenuItem"; + +/// `ui.action.insertMenuItem {set?, action?, step?, command, params?}`: inserts the command of a +/// menu item as a step, without running it. `command` and `params` (default: `{}`) are those of a +/// menu item that can be a step ([`crate::menus::insertable_items`]); the step plays as the item +/// runs. The place is as [`insert_stop`] takes it, and the step is selected as a stop is. A command +/// and params that no such item has, a param of another type, params that are not an object, or a +/// place [`insert_stop`] refuses is an error, and then nothing changes. Insert Menu Item's OK runs +/// this. → {set, action, step}, or {recording: true} +pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let command = p.get("command").and_then(Value::as_str).ok_or("command must be text")?; + let params = match p.get("params") { + None => json!({}), + Some(v) if v.is_object() => v.clone(), + Some(_) => return Err("`params` must be an object".into()), + }; + let items = crate::menus::insertable_items(app.session.prefs.font_names_in_english); + if !items.iter().any(|item| item.command == command && item.params == params) { + return Err("no menu item that can be inserted runs this command".into()); + } + insert(app, p, (command.to_string(), params)) +} + +/// Err when adding `step` to the recording in progress would give the action that Stop +/// Playing/Recording keeps more than [`files::MAX_ACTION_STEPS`] steps, or its set a file larger +/// than [`files::MAX_SET_FILE`], the most Load Actions… reads. +fn recording_room(app: &VectorcraftApp, step: &(String, Value)) -> Result<(), String> { + let Some((set, name, start)) = app.ui.recording.clone() else { return Err("no such action".into()) }; + let start = app.session.journal.held().unwrap_or(start); + let mut steps = app.session.journal_for_action(start); + let into = app.ui.recording_into; + let holder = into.and_then(|id| app.ui.action_sets.iter().position(|s| s.actions.iter().any(|a| a.id == id))).unwrap_or(set); + let Some(mut s) = app.ui.action_sets.get(holder).cloned() else { return Ok(()) }; + let at = match into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { + Some(at) => at, + None => { + s.actions.push(Action::new(name, vec![])); + s.actions.len() - 1 + } + }; + if s.actions[at].steps.len() + steps.len() >= files::MAX_ACTION_STEPS { + return Err("the action already has 10,000 steps".into()); + } + steps.push(step.clone()); + s.actions[at].steps.extend(steps); + if files::write_set(&s).len() > files::MAX_SET_FILE { + return Err("the action set would be larger than 8 MiB".into()); + } + Ok(()) +} + +/// What Stop Playing/Recording adds to a set ([`recorded_into`]). +struct Recorded { + /// The set's action that the recording records into; None for the new action that the + /// recording adds. + into: Option, + /// The new action's name. + name: String, + /// The steps recorded so far. + steps: Vec<(String, Value)>, +} + +/// What Stop Playing/Recording adds to set `set`; None when the recording in progress adds nothing +/// to it. +fn recorded_into(app: &VectorcraftApp, set: usize) -> Option { + let (recording_set, name, start) = app.ui.recording.as_ref()?; + let into = match app.ui.recording_into { + Some(id) => Some(app.ui.action_sets.get(set)?.actions.iter().position(|a| a.id == id)?), + None if *recording_set == set => None, + None => return None, + }; + let steps = app.session.journal_for_action(app.session.journal.held().unwrap_or(*start)); + Some(Recorded { into, name: name.clone(), steps }) +} + +/// Puts `step` where params `p` say ([`insert_stop`]) → what [`insert_stop`] returns. When the +/// recording in progress adds steps to the set, the limits count them as Stop Playing/Recording +/// would leave the set. +fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); if p.get("set").is_none() && p.get("action").is_none() && p.get("step").is_none() { if app.ui.recording.is_none() { return Err("no such action".into()); } + recording_room(app, &step)?; app.session.journal.push(step); return Ok(json!({"recording": true})); } let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; - let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?; + let recorded = recorded_into(app, set); + let pending = match &recorded { + Some(r) if r.into == Some(at) => r.steps.len(), + _ => 0, + }; + let s = app.ui.action_sets.get_mut(set).filter(|s| at < s.actions.len()).ok_or("no such action")?; + let action = &mut s.actions[at]; let after = match p.get("step") { None => action.steps.len(), Some(_) => index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1, }; + if action.steps.len() + pending >= files::MAX_ACTION_STEPS { + return Err("the action already has 10,000 steps".into()); + } + let excluded = action.excluded_steps.clone(); action.insert_step(after, step); let id = action.id; + let file_len = match recorded { + None => files::write_set(s).len(), + Some(Recorded { into, name, steps }) => { + let mut kept = s.clone(); + match into { + Some(into) => kept.actions[into].steps.extend(steps), + None => kept.actions.push(Action::new(name, steps)), + } + files::write_set(&kept).len() + } + }; + if file_len > files::MAX_SET_FILE { + let action = &mut s.actions[at]; + action.steps.remove(after); + action.excluded_steps = excluded; + return Err("the action set would be larger than 8 MiB".into()); + } app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; if let Some(ctx) = app.ui_ctx.clone() { @@ -922,8 +1036,8 @@ pub const DELETE_STEP: &str = "ui.action.deleteStep"; /// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the /// action at index `action` of set `set`. The steps after it keep whether they are excluded, and -/// no step of that action stays selected. A Record Stop dialog open on that step closes, and one -/// open on a later step of the action follows it up. A missing or invalid index is an error, and +/// no step of that action stays selected. A Record Stop or Insert Menu Item dialog open on that step +/// closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and /// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the /// selected step. → {set, action, step} pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -943,7 +1057,7 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result .ui .dialog .as_ref() - .filter(|d| d.kind == crate::dialogs::action_stop::RECORD_KIND) + .filter(|d| holds_step(d)) .filter(|d| d.fields.get("set").and_then(number) == Some(set) && d.fields.get("action").and_then(number) == Some(at)) .and_then(|d| d.fields.get("step").and_then(number)); if held == Some(step) { @@ -1190,10 +1304,16 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result fn holds_set(d: &crate::state::Dialog) -> bool { d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_new::KIND - || d.kind == crate::dialogs::action_stop::RECORD_KIND + || holds_step(d) || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) } +/// Whether dialog `d` is a Record Stop or Insert Menu Item dialog, which holds the place of a new +/// step in its `set`, `action` and `step` fields. +fn holds_step(d: &crate::state::Dialog) -> bool { + d.kind == crate::dialogs::action_stop::RECORD_KIND || d.kind == crate::dialogs::action_insert_menu_item::KIND +} + /// The command that duplicates an action or an action set ([`duplicate`]). pub const DUPLICATE: &str = "ui.actionSet.duplicate"; @@ -1240,9 +1360,9 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) }; copy.name = format!("{} copy", copy.name); src.actions.insert(a + 1, copy); - // A Batch or Record Stop dialog open on a later action of the set follows it down. - if let Some(d) = - app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_stop::RECORD_KIND) + // A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it + // down. + if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d)) && d.fields.get("set").and_then(index) == Some(set) && let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a) { @@ -1317,6 +1437,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let step = selected_step(app, ui.ctx(), selected); crate::dialogs::action_stop::open_record(app, if recording { None } else { selected }, step); } + if widgets::menu_item(ui, tl!("Insert Menu Item…"), recording || has, false) { + let step = selected_step(app, ui.ctx(), selected); + crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step); + } if widgets::menu_item(ui, tl!("Action Options…"), has, false) && let Some((s, a)) = selected { @@ -3963,6 +4087,253 @@ mod tests { assert!(app.ui.action_sets[0].actions.last().unwrap().steps.is_empty(), "the refused stop is not recorded"); } + #[test] + fn insert_menu_item_goes_after_the_given_step_or_at_the_end_runs_nothing_and_plays_as_the_menu_runs_it() { + let (mut app, x) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + let doc = app.session.active().unwrap().doc.clone(); + let journal = app.session.journal.len(); + let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 1})); + let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "type.changeCase", "params": {"case": "upper"}})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 4})); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "inserting runs nothing"); + assert_eq!(app.session.journal.len(), journal, "and records nothing"); + let action = &app.ui.action_sets[0].actions[0]; + let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", "object.group", "object.move", "object.move", "type.changeCase"]); + assert_eq!((&action.steps[1].1, &action.steps[4].1), (&json!({}), &json!({"case": "upper"}))); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it"); + + app.ui.action_sets[0].actions[0].excluded_steps.insert(4); + let before = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + let results = r["results"].as_array().unwrap(); + assert_eq!(results.len(), 2, "the group and the included move play, and the excluded change of case does not: {r}"); + assert!(results[0]["id"].is_u64(), "the group step made a group: {r}"); + assert_eq!(undo_steps(&app), before + 1, "the action plays as one undo step"); + assert_eq!(x(&app), 2.0, "the move of 2 moved the group"); + } + + #[test] + fn an_inserted_menu_item_plays_step_by_step() { + let (mut app, x) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].steps.truncate(1); + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap(); + app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap(); + let rect = app.session.active().unwrap().selection.objects.first().copied(); + let before = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!(r["pending"], json!(1), "the move ran, the group waits: {r}"); + play_out(&mut app); + assert_eq!(undo_steps(&app), before + 2, "each step its own undo step"); + assert_eq!(x(&app), 1.0); + let selected = app.session.active().unwrap().selection.objects.first().copied(); + assert!(selected.is_some() && selected != rect, "the group step ran and selected the new group"); + } + + #[test] + fn insert_menu_item_refuses_what_no_menu_item_runs_and_changes_nothing() { + let (mut app, _) = app_with_three_moves(); + let place = |extra: Value| { + let mut p = json!({"set": 0, "action": 0}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + p + }; + let none = "no menu item that can be inserted runs this command"; + for (params, want) in [ + (place(json!({})), "command must be text"), + (place(json!({"command": 5})), "command must be text"), + (place(json!({"command": "no.such.command"})), none), + // Undo, a File menu item, an item that asks for values first, the app's own commands and + // an action's own command. + (place(json!({"command": "edit.undo"})), none), + (place(json!({"command": "file.save"})), none), + (place(json!({"command": "object.move"})), none), + (place(json!({"command": "object.move", "params": {"dx": 1, "dy": 0}})), none), + (place(json!({"command": "view.zoomIn"})), none), + (place(json!({"command": INSERT_MENU_ITEM})), none), + (place(json!({"command": "edit.paste"})), none), + // Align, whose menu items add the Align To choice when clicked. + (place(json!({"command": "object.align", "params": {"horizontal": "left"}})), none), + // Image Trace, whose menu items trace off the UI thread with progress and Cancel. + (place(json!({"command": "imageTrace.make", "params": {"preset": "Default"}})), none), + (place(json!({"command": "imageTrace.makeAndExpand", "params": {"preset": "Default"}})), none), + // Pattern's Make and Edit Pattern, whose menu items open the Pattern Options panel. + (place(json!({"command": "object.pattern.make"})), none), + (place(json!({"command": "object.pattern.edit"})), none), + // Params that no menu item gives its command. + (place(json!({"command": "type.changeCase"})), none), + (place(json!({"command": "type.changeCase", "params": {"case": "middle"}})), none), + (place(json!({"command": "object.group", "params": {"ids": [1]}})), none), + (place(json!({"command": "object.group", "params": [1]})), "`params` must be an object"), + (place(json!({"command": "object.group", "params": null})), "`params` must be an object"), + (place(json!({"command": "object.group", "step": 3})), "no such action step"), + (place(json!({"command": "object.group", "step": null})), "no such action step"), + (json!({"command": "object.group"}), "no such action"), + (json!({"command": "object.group", "set": 0}), "no such action"), + (json!({"command": "object.group", "set": 0, "action": 1}), "no such action"), + (json!({"command": "object.group", "set": 1, "action": 0}), "no such action"), + (json!(5), "`params` must be an object"), + (json!(null), "`params` must be an object"), + ] { + assert_eq!(app.run(INSERT_MENU_ITEM, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + } + + /// A listed menu item runs from the menu what its step runs in playback: the click opens no + /// dialog, and it journals the item's own command with the item's own params. + #[test] + fn every_listed_menu_item_runs_from_the_menu_what_its_step_holds() { + for item in crate::menus::insertable_items(false) { + let (mut app, _) = app_with_three_moves(); + let start = app.session.journal.len(); + crate::menus::invoke(&mut app, item.command, item.params.clone()); + assert!(app.ui.dialog.is_none(), "{item:?} opens a dialog"); + let ran = app.session.journal_for_action(start); + assert!(ran.iter().all(|(c, p)| c != item.command || *p == item.params), "{item:?} ran {ran:?}"); + } + } + + #[test] + fn insert_menu_item_goes_into_the_recording() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap(), json!({"recording": true})); + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "file.save"})).unwrap_err(), "no menu item that can be inserted runs this command"); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps, [("object.group".to_string(), json!({}))]); + } + + /// An action stays one that Load Actions… reads back: a stop or a menu item that would give it + /// more than 10,000 steps, or its set's file more than 8 MiB, is refused, and the action keeps its + /// steps and exclusions as they were. + #[test] + fn inserted_steps_keep_an_action_set_that_load_actions_reads() { + let (mut app, _) = app_with_three_moves(); + let full = vec![("object.group".to_string(), json!({})); files::MAX_ACTION_STEPS]; + app.ui.action_sets[0].actions[0].steps = full; + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), steps); + assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap_err(), steps); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), files::MAX_ACTION_STEPS); + app.ui.action_sets[0].actions[0].steps.pop(); + assert!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).is_ok(), "the 10,000th step goes in"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + + let pad = |n: usize| vec![("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)}))]; + app.ui.action_sets[0].actions[0].steps = pad(0); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 7]); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = pad(room - 8); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap_err(), size); + assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), size); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!((action.steps.len(), &action.excluded_steps), (1, &BTreeSet::from([0, 7])), "nothing changed"); + assert!(files::write_set(&app.ui.action_sets[0]).len() <= files::MAX_SET_FILE); + } + + /// The limits hold for a step inserted into the recording in progress too: they count the steps + /// recorded so far and, for New Action's recording, the action's own steps and its set. + #[test] + fn steps_inserted_into_a_recording_keep_an_action_set_that_load_actions_reads() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS - 1 { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run(INSERT_STOP, json!({})).unwrap(), json!({"recording": true}), "the 10,000th step goes in"); + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), steps); + assert_eq!(app.run(INSERT_STOP, json!({})).unwrap_err(), steps); + app.run(STOP_RECORDING, json!({})).unwrap(); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - 8)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), size); + assert_eq!(app.run(INSERT_STOP, json!({})).unwrap_err(), size); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "nothing was recorded into the action"); + assert!(files::write_set(&app.ui.action_sets[0]).len() <= files::MAX_SET_FILE); + } + + /// A step put by its set and action into the action that the recording in progress records into + /// counts the steps recorded so far, which Stop Playing/Recording adds to that action. + #[test] + fn steps_inserted_into_the_action_being_recorded_into_count_the_recorded_steps() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions.push(Action::new("Other", vec![])); + let into = app.ui.action_sets[0].actions[0].id; + let stored = app.ui.action_sets[0].actions[0].steps.len(); + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + for _ in stored..files::MAX_ACTION_STEPS { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), steps); + assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), steps); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), stored, "nothing was inserted"); + assert!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 1, "command": "object.group"})).is_ok(), "another action takes the step"); + app.run(STOP_RECORDING, json!({})).unwrap(); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + let into = app.ui.action_sets[0].actions[0].id; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.session.journal.push(pad(room - 8)); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), size); + assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), size); + // Another action of the set counts them too, for a recording into an action and for a + // recording that adds a new action. + for into_first in [true, false] { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions.push(Action::new("Other", vec![])); + let first = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = into_first.then_some(first); + app.session.journal.push(pad(room - 8)); + let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 1, "command": "object.group"})); + assert_eq!(r.unwrap_err(), size, "recording into the first action: {into_first}"); + assert!(app.ui.action_sets[0].actions[1].steps.is_empty(), "recording into the first action: {into_first}"); + } + } + + /// A step inserted from the menu saves and loads as the command and params it holds, with its + /// place and exclusion. + #[test] + fn an_inserted_menu_item_saves_and_loads() { + let (mut app, _) = app_with_three_moves(); + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 1, "command": "type.changeCase", "params": {"case": "title"}})).unwrap(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + let loaded = files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").unwrap(); + let action = &loaded.actions[0]; + assert_eq!(action.steps, app.ui.action_sets[0].actions[0].steps); + assert_eq!(action.steps[2], ("type.changeCase".to_string(), json!({"case": "title"}))); + assert_eq!(action.excluded_steps, BTreeSet::from([2])); + } + #[test] fn delete_step_removes_one_step_and_moves_the_exclusions_up() { let (mut app, _) = app_with_a_stop(false); @@ -4004,6 +4375,22 @@ mod tests { assert_eq!(open_kind(&app), None, "deleting its step closes it"); } + #[test] + fn step_and_set_edits_keep_an_open_insert_menu_item_on_its_place() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions.insert(0, Action::new("First", vec![])); + crate::dialogs::action_insert_menu_item::open(&mut app, Some((0, 1)), Some(2)); + app.run(DUPLICATE, json!({"set": 0, "action": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["action"], json!(2), "a copied action above moves it down"); + app.run(DELETE_STEP, json!({"set": 0, "action": 2, "step": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(1), "a deleted step above moves it up"); + app.run(DELETE_STEP, json!({"set": 0, "action": 2, "step": 1})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its step closes it"); + crate::dialogs::action_insert_menu_item::open(&mut app, Some((0, 0)), None); + app.run(DELETE_SETS, json!({"sets": [0]})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its set closes it"); + } + /// Selects the first step of "Three Moves" by clicking its row, with the action open. fn click_first_step(ctx: &egui::Context, app: &mut VectorcraftApp) { use super::super::tests_appearance::{click, frame_events}; @@ -4474,6 +4861,58 @@ mod tests { assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3); } + /// The panel menu's Insert Menu Item… after the selected step: Find narrows the list, a click on + /// a row picks the item and runs nothing, Cancel inserts nothing, and OK inserts the pick after + /// the step. With nothing selected and nothing recording, the item is off. + #[test] + fn insert_menu_item_in_the_menu_picks_from_the_list_and_inserts_on_ok() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let open = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Menu Item…").map(|(_, r)| *r).expect("Insert Menu Item… in the menu"); + click(ctx, app, item.center(), menu); + }; + open(&ctx, &mut app); + assert_eq!(open_kind(&app), None, "nothing selected and nothing recording: the item is off"); + + click_first_step(&ctx, &mut app); + open(&ctx, &mut app); + let d = app.ui.dialog.as_ref().expect("Insert Menu Item… opens its dialog"); + assert_eq!(d.kind, crate::dialogs::action_insert_menu_item::KIND); + assert_eq!((&d.fields["set"], &d.fields["action"], &d.fields["step"]), (&json!(0), &json!(0), &json!(0))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("object.ungroup")); + } + let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::dialogs::show(app, ui.ctx()); + let mut texts = vec![]; + for _ in 0..3 { + texts = frame_events(&ctx, &mut app, vec![], draw); + } + assert!(!texts.iter().any(|(s, _)| s == "Object › Group"), "Find narrows the list: {texts:?}"); + let doc = app.session.active().unwrap().doc.clone(); + click(&ctx, &mut app, text_rect(&texts, "Object › Ungroup").center(), draw); + let d = app.ui.dialog.as_ref().expect("a pick keeps the dialog open"); + assert_eq!((d.str("command"), &d.fields["params"]), ("object.ungroup".to_string(), &json!({}))); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "a pick runs nothing"); + + crate::dialogs::cancel(&mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing"); + + open(&ctx, &mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("command".into(), json!("object.ungroup")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(open_kind(&app), None, "OK closes the dialog"); + let ids: Vec = app.ui.action_sets[0].actions[0].steps.iter().map(|(c, _)| c.clone()).collect(); + assert_eq!(ids, ["object.move", "object.ungroup", "object.move", "object.move"], "the pick goes after the selected step"); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((app.ui.action_sets[0].actions[0].id, 1)), "and is selected"); + } + #[test] fn set_edits_keep_an_open_record_stop_on_its_action() { let mut app = app_with_sets(); diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index f7df78ee9..2e1385a7d 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -34,7 +34,7 @@ pub(super) const MAX_SET_FILE: usize = 8 << 20; const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. -const MAX_ACTION_STEPS: usize = 10_000; +pub(super) const MAX_ACTION_STEPS: usize = 10_000; /// The most left-out steps that Load Actions… lists for an action set another app saved. const MAX_SKIPPED: usize = 1000; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ad9af9f27..4467dcec6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -537,6 +537,10 @@ pub struct UiState { /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option, + /// The notes of the last open that gave any, with the status text that names the first of + /// them: hovering that text lists them when there are several. Not saved. + #[serde(skip)] + pub status_notes: Option<(String, Vec)>, pub palette_open: bool, pub palette_query: String, /// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. Not saved: @@ -1065,6 +1069,7 @@ impl Default for UiState { group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), status: String::new(), replace_font_warning: None, + status_notes: None, palette_open: false, palette_query: String::new(), screen_mode: 0, diff --git a/crates/ui-egui/tests/flatpak_updates.rs b/crates/ui-egui/tests/flatpak_updates.rs new file mode 100644 index 000000000..c0ffb69fd --- /dev/null +++ b/crates/ui-egui/tests/flatpak_updates.rs @@ -0,0 +1,27 @@ +//! Release and source Flatpaks must allow release checks and host update helpers. +fn finish_args(manifest: &str) -> Vec<&str> { + manifest + .split("finish-args:") + .nth(1) + .unwrap_or_default() + .lines() + .take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#')) + .filter_map(|line| line.trim().strip_prefix("- ")) + .map(|arg| arg.trim_matches(['\'', '"'])) + .collect() +} + +#[test] +fn flatpak_update_permissions() { + let manifests = [ + include_str!("../../../packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"), + include_str!("../../../packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"), + ]; + let args = manifests.map(finish_args); + assert_eq!(args[0], args[1], "source and release permissions must agree"); + for permissions in args { + for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] { + assert!(permissions.contains(&required), "missing update permission: {required}"); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 966384142..e302ce633 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b1bc1fa38..2602b78bc 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/mcp.md b/docs/mcp.md index 5690662b3..4d32a4a6b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2739,8 +2739,26 @@ already walks Image Trace. Short agent entry points: false) offers Continue in the message box. An action with an empty message still waits at the stop, which is VectorCraft's own rule. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an - object, or no action and no recording is an error, and then nothing changes. The panel menu's Insert Stop… opens the `actionStopRecord` dialog + object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file + more than 8 MiB is an error, and then nothing changes. The limits count the steps that the recording in progress + adds to the set. The panel menu's Insert Stop… opens the `actionStopRecord` dialog (`message`, `allowContinue`, `set`, `action`, `step`), whose OK runs this. +- Actions: `ui.action.insertMenuItem {set?, action?, step?, command, params?}` inserts a menu item's command as a step + without running it. `command` and `params` (default: `{}`) must be those of an item in the menus that can be a step: + an item that runs a document command without opening a dialog first, outside the File, Window and Help menus, and + not Undo, Redo, a Paste command, Object › Align (its items add the Align To choice when clicked), Object › Image + Trace › Make or Make and Expand (the menu traces off the UI thread, with progress and Cancel), Object › Pattern › + Make or Edit Pattern (the menu also opens the Pattern Options panel), a preference + toggle, an Actions command or a recent-font, saved-selection or saved-view slot. Disabled items are listed, and playback checks the step when it runs. With `set` and `action`, the + step goes into that action, after step `step`, else after its last step, becomes the panel's selected step, and the + command returns `{set, action, step}`. Without them it goes into the recording in progress and returns + `{recording: true}`. A command no such item runs, params that are not an object, an index past the last set, action + or step, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file + more than 8 MiB is an error, and then nothing changes. The limits count the steps that the recording in progress + adds to the set. The panel menu's Insert Menu Item… opens the + `actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing + Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and + OK runs this. - Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an @@ -3033,6 +3051,32 @@ The document stores their numeric settings; manual edits remain possible through {"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}} ``` +## 3D shadows + +Revolve, Extrude & Bevel, Inflate and Rotate take `shadow` (boolean, default false), `shadowPosition` (`"behind"` +or `"below"`, default `"behind"`), `shadowDistance` (0..100%, default 0) and `shadowBounds` (10..400%, default 100), +set with `effect.apply` or `effect.setParams` in one undo step. With `shadow` on, the first light casts a +hard-edged shadow of the solid, drawn behind the solid as one path filled black at 50% opacity times the opacity +of the art that casts it, in that art's blending mode. `"behind"` puts it +on a plane square to the view behind the object; `"below"` puts it on the floor under the object, square to the +object's own vertical axis and turned with it, so with no X rotation the floor is seen edge-on and draws nothing. +`shadowDistance` moves the plane away from the object by that share of the object's largest size. `shadowBounds` +limits the plane to a square about the foot of the object's center, four times the object's largest size across +at 100%. No shadow is drawn when `shade` is false, when the first light's intensity is 0, or when it does not +reach the plane (a light behind the object for a shadow behind it); a fill, stroke or group member at 0% opacity +casts none. Values out of range, another `shadowPosition` and a `shadow` that is not a boolean are refused before +the document changes. A shadow is cast from at most 327,680 faces, the solids of one object together (VectorCraft's +own limit); past that, a command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the +art", and a loaded file draws the source path, as it does for a solid past its limits. The color, the opacity and how Distance and Bounds scale are VectorCraft's own; the public +documentation names the controls and their ranges +([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)). +The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps +it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows). + +```json +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}} +``` + ## 3D object type `effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}` diff --git a/docs/releasing.md b/docs/releasing.md index 64c177618..9de6abb3b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -42,6 +42,12 @@ pick that branch for a full run. Run on any other branch, it is a dry run: the L FreeBSD and web jobs build and upload their artifacts, the environment refuses the signing jobs, and no draft release is created. +Flatpak permission repairs need one update through the existing host-side installation method. +After that update, Help > Check for Updates can inspect and update standalone bundles. +Repository-managed installations continue to use their package manager; launcher-pinned +versions keep their selected version. Both Flatpak manifests must grant network access, +the host Flatpak interface, and read-only access to the shared version choices. + ## What gets built | Platform | Artifacts | Built on | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a0a0be7ed..6faacd571 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -558,6 +558,7 @@ since 2026-10-01. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. @@ -575,6 +576,7 @@ since 2026-10-01. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. +- **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -713,6 +715,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). + - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. @@ -1199,6 +1202,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. @@ -1348,6 +1352,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; nine generated style libraries (including Game HUD, Status Effects and Textures) save and load as `.vcstyles`. The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`). In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. + Applying a style to several objects, and Redefine Graphic Style, give the objects one shared copy of the style's appearance instead of a copy each; an object that is edited later gets its own copy, and an object whose placed gradients are fitted to its own bounds gets its own. The undo history's memory estimate counts a shared appearance once. Saved files are unchanged (`crates/engine/src/tests/styles.rs`). - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. @@ -1475,6 +1480,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). + EPS export and EPS Save make each file's PNG thumbnail from its TIFF preview, averaging the preview's pixels down to the thumbnail's size on white, instead of drawing the document a second time; a file without a preview still draws its thumbnail (`crates/engine/src/cmd/fileio/tests_eps.rs`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index 635c1285e..43f20dbf2 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -51,8 +51,9 @@ pages describe it. unavailable while a setting of a single light differs between the selected effects. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. -- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on - the artboard are part of the options dialog only. +- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and + ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the + options dialog only. ## Extrude & Bevel @@ -242,6 +243,39 @@ the default light had (45° left of the eye and 45° up). Effects saved before ( `lightAzimuth` and `lightElevation`; those are read as the Rotation, Height and side of the same direction, so the shading is exactly as before. +## Shadows + +The public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +page lists shadow settings that apply while shadows are on: a Position behind or below the object, a Distance +from Object from 0 to 100 % and Shadow Bounds from 10 to 400 %. The Lighting tab of Revolve, Extrude & Bevel, +Inflate and Rotate has a **Shadows** switch with those three settings below it. + +| Option | | +|---|---| +| Shadows | Off by default. On, the first light casts a hard-edged shadow of the solid, drawn behind it. | +| Position | **Behind Object** (the default): on a plane square to the view, behind the object. **Below Object**: on the floor under the object, square to its own vertical axis and turned with it. | +| Distance from Object | 0–100 %, default 0 %: how far the plane is from the object, as a share of the object's largest size. | +| Shadow Bounds | 10–400 %, default 100 %: how far the plane reaches. At 100 % it is a square four times the object's largest size across, about the foot of the object's center; the shadow is cut at its edges. | + +Our reading: + +- The shadow is one path, filled black at 50 % opacity with the nonzero rule: every face of every solid of the + object (its fill and its strokes, or the members of a group) projected along the first light onto the plane. + Art that is partly transparent casts its part at 50 % times its opacity, and art with a blending mode casts + its part in that mode, each as a path of its own. A member whose + opacity mask or blending mode applies to its solids casts its part in that mask and mode too. The color, the + opacity and how Distance and Bounds scale are our own. +- No shadow is drawn while Shading is off (the Shadows settings are then unavailable), when the first light's + Intensity is 0 %, or when it does not reach the plane: a light behind the object casts none behind it, and a + light from under the floor casts none below it. A fill, stroke or group member at 0 % opacity casts none. With no X rotation the floor is seen edge-on, so a + shadow below draws nothing until the object is tilted (the default Off-Axis Front position shows it). +- Softness is not applied to the shadow, and only the first light casts one. +- A shadow is cast from at most 327,680 faces, the solids of one object together (our own limit). Past it, a + command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded + file draws the source path. +- Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a + path below the faces. Extrude & Bevel (Classic) has no shadow. + ## Parameters (`effect.apply`, `effect.setParams`) `threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, @@ -249,7 +283,7 @@ shading is exactly as before. `threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightColor` (`#rrggbb`, default `#ffffff`), `lightIntensity` (%), `lightRotation`, `lightHeight` (degrees), `lightSoftness` (%), `ambient` (%, to 200), `lightBehind`. Lengths are points and scale with the object (Scale Strokes & Effects). -Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely. +Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely. All four take `shadow` (boolean, false), `shadowPosition` (`behind` or `below`), `shadowDistance` (0..100%, 0) and `shadowBounds` (10..400%, 100) ([Shadows](#shadows)); values out of range are rejected before editing. ## What differs from the public documentation @@ -258,9 +292,10 @@ and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effe pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or out, perspective). No Adobe software, assets, presets or bevel shapes were used. -- **Materials** (base, Adobe Substance and graphics), **shadows** +- **Materials** (base, Adobe Substance and graphics), soft shadows, shadows of lights after the first, and **ray-traced rendering** are not built: shading uses directional lights, in the paint of the fill or - stroke the solid is built for ([Fills and strokes](#fills-and-strokes)). + stroke the solid is built for ([Fills and strokes](#fills-and-strokes)), and the shadow is hard-edged + ([Shadows](#shadows)). - The bevel shapes and their names are our own; custom bevel paths are not supported. - Visibility is a painter's algorithm: faces of a closed solid turned away are left out, and the rest are drawn back to front, with a flat cap or flat back turned toward the viewer drawn after all of them. Concave solids diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index 840abbc53..b3b4ad194 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -17,6 +17,8 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: vectorcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml index 681763793..7d14dbe01 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml @@ -17,6 +17,8 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: vectorcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # Keep identical to ai.storyteller.vectorcraft.bundle.yml (the release bundle); comments there. - --share=ipc