diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml deleted file mode 100644 index c638bcef5..000000000 --- a/.github/workflows/windows7.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and -# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts -# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build -# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host -# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support. -name: Windows 7 x64 compatibility - -on: - pull_request: - paths: - - "Cargo.toml" - - "Cargo.lock" - - "apps/vectorcraft/Cargo.toml" - - "apps/vectorcraft/src/printing.rs" - - "apps/vectorcraft/src/printing/**" - - "crates/ui-egui/src/print.rs" - - "crates/ui-egui/src/tests/printps.rs" - - "packaging/windows/windows7.ps1" - - ".github/workflows/windows7.yml" - schedule: - - cron: "23 5 * * 1" # weekly - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - -jobs: - build: - runs-on: windows-2022 - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 - with: - arch: x64 - - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master - with: - toolchain: nightly-2026-10-01 - components: rust-src - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: windows7-x64 - - name: Build, check and test the portable package - shell: pwsh - run: ./packaging/windows/windows7.ps1 -Test - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: vectorcraft-windows7-x64-portable - path: dist/release/vectorcraft-windows7-x64-portable.zip - if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index cbf9de129..0460b6192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input. - Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort. - Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible. + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it. - Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them. - Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why. - Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that. diff --git a/Cargo.lock b/Cargo.lock index d329c26a7..78306baae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,7 +1310,6 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "image", @@ -3283,6 +3282,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6053,8 +6053,10 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-core-text", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "pollster 1.0.1", "raw-window-handle", "rfd", @@ -6077,7 +6079,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.17.0" +version = "0.18.0" dependencies = [ "crc32fast", "flate2", @@ -6088,7 +6090,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6100,7 +6102,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6116,7 +6118,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "log", "objc2-core-foundation", @@ -6138,7 +6140,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6150,7 +6152,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6169,7 +6171,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6185,7 +6187,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "crc32fast", @@ -6224,7 +6226,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "flate2", @@ -6248,7 +6250,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "proptest", @@ -6263,7 +6265,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6275,7 +6277,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "log", @@ -6294,7 +6296,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6308,7 +6310,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6320,12 +6322,13 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.17.0" +version = "0.18.0" dependencies = [ "aes", "chrono", "craft-pdf", "flate2", + "getrandom 0.4.3", "image", "jpeg-encoder", "krilla", @@ -6351,7 +6354,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.17.0" +version = "0.18.0" dependencies = [ "serde", "serde_json", @@ -6365,7 +6368,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "gif", @@ -6394,14 +6397,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "flate2", @@ -6426,7 +6429,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "image", @@ -6444,7 +6447,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.17.0" +version = "0.18.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6465,7 +6468,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.17.0" +version = "0.18.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6473,7 +6476,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6489,7 +6492,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6501,7 +6504,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6539,7 +6542,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "eframe", "egui", @@ -7670,7 +7673,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.17.0" +version = "0.18.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 644bffe9d..c134a3c19 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.17.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -116,6 +116,9 @@ webbrowser = "1" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see # AGENTS.md › Robustness). Tests may unwrap (clippy.toml). diff --git a/NOTICE b/NOTICE index 8698b2dfe..0d0758820 100644 --- a/NOTICE +++ b/NOTICE @@ -30,11 +30,6 @@ based in part on the work of the Independent JPEG Group. WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is under the Apache License 2.0 with the LLVM exception. -The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the -windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree -through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every -other build uses the unmodified crate from crates.io. - Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest; diff --git a/README.md b/README.md index d85cef156..fd45eb9cd 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,6 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-roo The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md). -For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md). - ## Status VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension @@ -249,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files, and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). The interface -speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian; +speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan; [localization-parity.md](docs/localization-parity.md) has the details. The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index 28c15f326..8231428ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,7 +112,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Korean | `ko` | full | 100% | | Vietnamese | `vi` | full | 100% | -Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending). +Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending). The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md). ## Upcoming diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 82f0b100d..cb9183315 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -35,7 +35,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR # Apache-2.0. -[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 6ec320ea2..bba1976c1 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -46,7 +46,7 @@ mod perf; #[path = "../../vectorcraft/src/prefs_dir.rs"] mod prefs_dir; /// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts. -#[cfg(all(windows, not(target_vendor = "win7")))] +#[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; @@ -162,7 +162,7 @@ fn help() -> Result<(), String> { fn main() -> ExitCode { // Before any font lookup: the fonts font services load (#579). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] system_fonts::install(); #[cfg(target_os = "macos")] mac_fonts::install(); diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index c45c5d867..9caf4bc92 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() { let mut stdin = child.stdin.take().unwrap(); let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); let mut notes = vec![]; - - fn send(stdin: &mut std::process::ChildStdin, v: Value) { - writeln!(stdin, "{v}").unwrap(); + exercise_mcp_core(&mut stdin, &mut lines, &mut notes); + exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes); + exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes); + exercise_mcp_logging(&mut stdin, &mut lines, &mut notes); + exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes); + drop(stdin); + for line in lines { + let v: Value = serde_json::from_str(&line.unwrap()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + notes.push(v); } - // Next reply with this id; notifications (no id) are stashed for later. - fn recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { - loop { - let line = lines.next().unwrap().unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - if v.get("id").and_then(|i| i.as_u64()) == Some(id) { - return v; - } - notes.push(v); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); + let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); + assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); +} + +fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) { + writeln!(stdin, "{v}").unwrap(); +} + +fn mcp_recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { + loop { + let line = lines.next().unwrap().unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + if v.get("id").and_then(|i| i.as_u64()) == Some(id) { + return v; } + notes.push(v); } +} - send( - &mut stdin, +fn exercise_mcp_core( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), ); - let r = recv(&mut lines, &mut notes, 1); + let r = mcp_recv(lines, notes, 1); assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft"); assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); - send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); - let r = recv(&mut lines, &mut notes, 2); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); + let r = mcp_recv(lines, notes, 2); assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); - let r = recv(&mut lines, &mut notes, 3); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); + let r = mcp_recv(lines, notes, 3); assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}"); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}), ); - let r = recv(&mut lines, &mut notes, 4); + let r = mcp_recv(lines, notes, 4); assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); - send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); + assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); + assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}), ); - let r = recv(&mut lines, &mut notes, 7); + let r = mcp_recv(lines, notes, 7); assert_eq!(r["result"]["isError"], false, "{r}"); let id: i64 = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap(); - send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); - let r = recv(&mut lines, &mut notes, 8); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); + let r = mcp_recv(lines, notes, 8); let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["id"].as_i64(), Some(id), "{body}"); +} +fn exercise_mcp_resource_errors( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Unknown resources (-32002) and bad template values (-32602) stay apart. - send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); - assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002); - send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); - assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); + assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); + assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602); +} +fn exercise_mcp_document_resources( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Large containers slice through run_command document.node: a truncated level reports // childCount, and a bad limit is a tool error, not a crash or a full read. - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}), ); - assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false); - let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}); - send(&mut stdin, run(21, "select.all", json!({}))); - recv(&mut lines, &mut notes, 21); - send(&mut stdin, run(22, "object.group", json!({}))); - let r = recv(&mut lines, &mut notes, 22); + assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false); + mcp_send(stdin, run_command(21, "select.all", json!({}))); + mcp_recv(lines, notes, 21); + mcp_send(stdin, run_command(22, "object.group", json!({}))); + let r = mcp_recv(lines, notes, 22); let group = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); - send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); - let r = recv(&mut lines, &mut notes, 23); + mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); + let r = mcp_recv(lines, notes, 23); let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}"); assert_eq!(sliced["childCount"], 2, "{sliced}"); - send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); - assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true); + mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); + assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true); // Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and // document.find locates nodes without dumping the tree. - send(&mut stdin, run(25, "document.inspect", json!({"depth": 0}))); - let r = recv(&mut lines, &mut notes, 25); + mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0}))); + let r = mcp_recv(lines, notes, 25); let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert!(skel["layers"][0].get("children").is_none(), "{skel}"); assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}"); - send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"}))); - let r = recv(&mut lines, &mut notes, 26); + mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"}))); + let r = mcp_recv(lines, notes, 26); let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(found["total"], 1, "{found}"); assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}"); - send(&mut stdin, run(27, "document.find", json!({}))); - assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true); + mcp_send(stdin, run_command(27, "document.find", json!({}))); + assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true); +} +fn exercise_mcp_logging( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // The binary installed its logger: records arrive as notifications/message, and only // after the client asked for them. - send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); - assert!(recv(&mut lines, &mut notes, 11).get("error").is_none()); - send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); + assert!(mcp_recv(lines, notes, 11).get("error").is_none()); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); + assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); +} +fn exercise_mcp_parse_error( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Malformed input is a parse error, and the server keeps serving afterwards. stdin.write_all(b"{not json\n").unwrap(); let parse_err: Value = loop { @@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() { }; assert_eq!(parse_err["error"]["code"], -32700); assert!(parse_err["id"].is_null(), "{parse_err}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); - assert!(recv(&mut lines, &mut notes, 13).get("error").is_none()); - drop(stdin); - // Drain whatever the debug window queued; every line off the protocol stream parses. - for line in lines.by_ref() { - let line = line.unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - notes.push(v); - } - let out = child.wait_with_output().unwrap(); - assert!(out.status.success()); - assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); - let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); - assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); + assert!(mcp_recv(lines, notes, 13).get("error").is_none()); +} + +fn run_command(id: u64, command: &str, params: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}) } /// `vectorcraft-cli commands | head -1` panicked with "failed printing to diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 561fd0e84..1137c0d6b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -13,8 +13,6 @@ workspace = true default = ["wgpu", "accessibility"] wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] -# Build with --no-default-features --features windows7 and the win7 Rust target. -windows7 = ["eframe/glow"] [dependencies] vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } @@ -37,9 +35,6 @@ image = { workspace = true } webbrowser = { workspace = true } # The window's monitors, size and position (the same winit eframe runs on). winit = { version = "0.30", default-features = false } - -# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows. -[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies] craft-print = { workspace = true } [target.'cfg(windows)'.dependencies] @@ -56,7 +51,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. -[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "macos")'.dependencies] @@ -64,7 +58,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G # with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.2", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } # Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" @@ -76,6 +70,10 @@ raw-window-handle = "0.6" # objc2-foundation. objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] } objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] } +# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs). +# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features. +objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] } +objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] } # Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0, # already in the build through eframe. pollster = "1" diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index c7d6ada32..2847004b3 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,5 +1,11 @@ +#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] +use super::gpu; use super::prefs::save_prefs; -use super::*; +#[cfg(all(windows, not(target_vendor = "win7")))] +use super::windows_stylus; +use super::{App, VectorcraftApp, file_access, window}; +#[cfg(target_os = "macos")] +use super::{mac_menu, mac_window, open_documents}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -10,12 +16,12 @@ pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option Option { Some(std::sync::Arc::new(windows_stylus::raw_input_hook)) } -#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))] +#[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option { None } @@ -41,9 +47,13 @@ impl eframe::App for App { mac_menu::raw_input_hook(raw); } // A pen contact with the barrel button held presses the secondary button (Windows Ink). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] windows_stylus::raw_input_hook(raw); file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); + // A mouse-up that never reached the window (#4326) is delivered once AppKit reports + // that no mouse button is down. + #[cfg(target_os = "macos")] + self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); } #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -53,6 +63,9 @@ impl eframe::App for App { let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); } + // macOS converts the frame from sRGB to the display's profile (#2249). + #[cfg(all(target_os = "macos", feature = "wgpu"))] + super::mac_color_space::tag(ui); file_access::unconfined(|| self.app.ui(ui)); // Preferences are written when confirmed, not only at exit: a session ended any other way // (the system shutting down, the process ended or crashing) used to lose them (#1747). @@ -65,15 +78,10 @@ impl eframe::App for App { discard_marked_text(); } } - #[cfg(not(feature = "windows7"))] fn on_exit(&mut self) { file_access::unconfined(|| save_prefs(&self.app)); end_before_teardown(self.frames); } - #[cfg(feature = "windows7")] - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - file_access::unconfined(|| save_prefs(&self.app)); - } } impl App { diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 957d302d5..ef3832f1e 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -164,7 +164,9 @@ mod win { use clipboard_win::{formats, options::NoClear, raw}; - use super::*; + use super::{ + BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats, + }; /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the /// other or a `CF_BITMAP` is: the system converts between them. @@ -309,7 +311,7 @@ mod win { mod portable { use std::borrow::Cow; - use super::*; + use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; /// What our last write put on the clipboard. enum Ours { @@ -413,7 +415,7 @@ mod mac { use objc2_app_kit::NSPasteboard; use objc2_foundation::{NSArray, NSString}; - use super::*; + use super::{Flavour, pasteboard_types}; /// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only. pub fn has(mimes: &[&'static str]) -> bool { diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs index 7fa3f27e7..e3d946ef0 100644 --- a/apps/vectorcraft/src/control_server/tests.rs +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -4,12 +4,18 @@ fn short_timeouts() -> ConnectionTimeouts { ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } } -/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a -/// busy machine either thread can pause for more than 150 ms between connecting and sending, or -/// between starting a reply and writing it, and a short budget then closes the connection with no -/// reply. +/// How long a test waits for the server to reply or to close a connection. The wait only ends a +/// test whose server never does either; the budgets under test are 120 and 150 ms, and 1 s in the +/// persistent test. On a busy machine the server's thread can pause for seconds before or between +/// its reads and writes, and a wait of a few seconds then fails a server that closes the +/// connection on time. +const WAIT: Duration = Duration::from_secs(20); + +/// The short line deadline, with idle and reply budgets longer than [`WAIT`]. On a busy machine +/// either thread can pause for more than 150 ms between connecting and sending, or between +/// starting a reply and writing it, and a short budget then closes the connection with no reply. fn line_timeouts() -> ConnectionTimeouts { - ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() } + ConnectionTimeouts { idle: Duration::from_secs(30), write: Duration::from_secs(30), ..short_timeouts() } } fn socket_fixture( @@ -27,12 +33,12 @@ fn socket_fixture( }); let client = TcpStream::connect(addr).unwrap(); socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); - client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + client.set_read_timeout(Some(WAIT)).unwrap(); (client, done, worker) } fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + let stopped = done.recv_timeout(WAIT).is_ok(); // Always unblock the old implementation too, so a failed regression leaves no worker behind. let _ = client.shutdown(std::net::Shutdown::Both); drop(client); @@ -93,7 +99,7 @@ fn trickled_control_line_has_an_absolute_deadline() { std::thread::sleep(Duration::from_millis(20)); } }); - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + let stopped = done.recv_timeout(WAIT).is_ok(); stop.store(true, Ordering::SeqCst); // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. let _ = client.shutdown(std::net::Shutdown::Both); @@ -110,26 +116,31 @@ fn a_nonreading_control_peer_cannot_retain_the_writer() { expect_socket_deadline(client, done, worker); } +/// Each request gets its own line and reply budgets. The handler takes longer than both budgets, +/// so a deadline kept from the previous request runs out. The line and reply budgets are 1 s, and +/// the idle budget is 30 s, longer than the client's [`WAIT`]: on a busy machine either thread can +/// pause for more than the short budgets of [`short_timeouts`] between starting a budget and the +/// read or write it covers. #[test] fn persistent_control_requests_get_fresh_transport_deadlines() { - let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; + let timeouts = ConnectionTimeouts { idle: Duration::from_secs(30), line: Duration::from_secs(1), write: Duration::from_secs(1) }; let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { // Application processing is not transport time; begin the reply budget afterwards. - std::thread::sleep(Duration::from_millis(200)); + std::thread::sleep(Duration::from_millis(1250)); Some(json!({"ok": true})) }); let mut reader = BufReader::new(client.try_clone().unwrap()); for id in [1, 2] { // The client sends each line in one write. `writeln!` on the socket sends the JSON in many - // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline - // between two of them closes the connection. + // small writes, and when the machine is busy, a pause of more than the line deadline between + // two of them closes the connection. client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); let mut line = String::new(); reader.read_line(&mut line).unwrap(); assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); } client.shutdown(std::net::Shutdown::Write).unwrap(); - done.recv_timeout(Duration::from_secs(3)).unwrap(); + done.recv_timeout(WAIT).unwrap(); worker.join().unwrap(); } @@ -155,7 +166,7 @@ fn timed_out_worker_returns_its_connection_slot() { let (tx, _rx) = channel(); let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); let start = std::time::Instant::now(); - while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { + while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < WAIT { std::thread::sleep(Duration::from_millis(10)); } let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; @@ -322,7 +333,7 @@ fn tcp_connection_is_closed_after_junk() { serve_lines(read, s, |_, _| Some(json!({"ok": true}))); }); let mut c = TcpStream::connect(addr).unwrap(); - c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.set_read_timeout(Some(WAIT)).unwrap(); c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection @@ -373,7 +384,7 @@ fn extra_connections_are_refused() { // Idle connections are accepted in order and held open. let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); let mut extra = TcpStream::connect(addr).unwrap(); - extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + extra.set_read_timeout(Some(WAIT)).unwrap(); let mut line = String::new(); BufReader::new(&mut extra).read_line(&mut line).unwrap(); assert!(line.contains("too many control connections"), "{line}"); diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 906e35fb6..d13a1c417 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -548,11 +548,14 @@ fn a_normal_exit_or_another_failure_does_not_restart() { assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); } -/// A log folder of its own for each test. -fn start_dir(tag: &str) -> std::path::PathBuf { +/// A log folder of its own for each test, and the guard that keeps other tests from starting a +/// program while it runs: the test releases the start file's lock and takes it again, and a child +/// starting meanwhile would keep it held (`crate::no_spawns`). +fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) { + let spawns = crate::no_spawns(); let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); - d + (d, spawns) } /// What a start in `dir` leaves out; it then ends without crashing. @@ -578,7 +581,7 @@ fn issue_2243() -> Vec { /// it, without a status-bar note. #[test] fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { - let dir = start_dir("crash"); + let (dir, _spawns) = start_dir("crash"); let c = issue_2243(); let first = Startup::default(); assert_eq!(first.track(&dir, None, false), LeftOut::default()); @@ -609,7 +612,7 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { /// an error, a restart on another adapter), leaves nothing out of the next. #[test] fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { - let dir = start_dir("shown"); + let (dir, _spawns) = start_dir("shown"); let c = issue_2243(); for end in [Startup::presented as fn(&Startup), Startup::ended] { let first = Startup::default(); @@ -633,7 +636,7 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { /// macOS, where wgpu has none. #[test] fn a_crash_while_listing_lists_opengl_alone() { - let dir = start_dir("listing"); + let (dir, _spawns) = start_dir("listing"); let first = Startup::default(); first.track(&dir, None, false); drop(first); @@ -660,7 +663,7 @@ fn a_crash_while_listing_lists_opengl_alone() { /// start shows its first frame on the adapter that crashed, the adapter counts again. #[test] fn wgpu_backend_or_adapter_name_ignores_the_crashes() { - let dir = start_dir("explicit"); + let (dir, _spawns) = start_dir("explicit"); let c = issue_2243(); let first = Startup::default(); first.track(&dir, None, false); @@ -681,7 +684,7 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() { /// are ignored: the start goes on with nothing left out. #[test] fn a_damaged_or_unreadable_start_file_is_ignored() { - let dir = start_dir("damaged"); + let (dir, _spawns) = start_dir("damaged"); std::fs::create_dir_all(&dir).unwrap(); let damaged: [&[u8]; 9] = [ b"", diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index aa7d7f42c..4254d1591 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,4 +1,12 @@ -use super::*; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +use super::portal_save; +#[cfg(target_os = "linux")] +use super::tablet; +#[cfg(all(windows, not(target_vendor = "win7")))] +use super::windows_stylus; +use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; +#[cfg(target_os = "macos")] +use super::{mac_save_format, mac_screen_color, mac_tablet}; /// The window file dialogs belong to. pub(crate) type Parent = Option>; @@ -266,7 +274,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { #[cfg(target_os = "macos")] stylus: mac_tablet::start(), // Windows reads it from the pen's pointer messages (`windows_stylus`). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] stylus: Some(windows_stylus::start()), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), diff --git a/apps/vectorcraft/src/mac_color_space.rs b/apps/vectorcraft/src/mac_color_space.rs new file mode 100644 index 000000000..3fa139480 --- /dev/null +++ b/apps/vectorcraft/src/mac_color_space.rs @@ -0,0 +1,139 @@ +//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to +//! the display's color profile (#2249, #3161). +//! +//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a +//! color space is present, Core Animation performs any necessary color space transformations when +//! compositing this content. The default value is `nil`, indicating that the rendered content +//! isn’t color-matched" +//! (). Without a +//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as +//! the display's most saturated red, brighter than the same color in an exported PNG. +//! +//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on +//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui +//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`] +//! adds a paint callback that draws nothing. egui calls it after the configuration and before the +//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A +//! layer that already has a color space keeps it. +//! +//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance. +#![allow(unsafe_code)] + +use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu}; +use objc2::MainThreadMarker; +use objc2::rc::Retained; +use objc2::{DowncastTarget, Message}; +use objc2_app_kit::NSApplication; +use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB}; +use objc2_quartz_core::{CALayer, CAMetalLayer}; + +/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its +/// top-left corner, in the background layer, with nothing drawn. +pub fn tag(ui: &egui::Ui) { + let ctx = ui.ctx(); + let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0)); + ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers)); +} + +struct TagLayers; + +impl CallbackTrait for TagLayers { + fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) { + // AppKit's windows and views are read on the main thread only, where eframe paints. + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + for window in NSApplication::sharedApplication(mtm).windows() { + if let Some(root) = window.contentView().and_then(|view| view.layer()) { + for layer in metal_layers(&root) { + tag_srgb(&layer); + } + } + } + } +} + +/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu +/// adds to it when it is a plain layer. +fn metal_layers(root: &CALayer) -> Vec> { + if let Some(layer) = downcast(root) { + return vec![layer]; + } + // SAFETY: the returned array is a copy; the layers in it are retained while it lives. + let Some(sublayers) = (unsafe { root.sublayers() }) else { + return Vec::new(); + }; + sublayers.iter().filter_map(|layer| downcast(&layer)).collect() +} + +fn downcast(layer: &CALayer) -> Option> { + layer.retain().downcast::().ok() +} + +/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set. +fn tag_srgb(layer: &CAMetalLayer) -> bool { + if layer.colorspace().is_some() { + return false; + } + // SAFETY: a constant that CoreGraphics exports. + let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else { + return false; + }; + layer.setColorspace(Some(&srgb)); + true +} + +#[cfg(test)] +mod tests { + use objc2_core_foundation::CFString; + use objc2_core_graphics::kCGColorSpaceDisplayP3; + + use super::*; + + fn space_name(layer: &CAMetalLayer) -> Option { + CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string()) + } + + fn name(constant: &CFString) -> String { + constant.to_string() + } + + /// A layer without a color space is tagged sRGB once; the second call leaves it as it is. + #[test] + fn an_untagged_layer_becomes_srgb() { + let layer = CAMetalLayer::new(); + assert_eq!(space_name(&layer), None, "a new layer has no color space"); + assert!(tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB }))); + assert!(!tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB }))); + } + + /// A layer with a color space of its own keeps it. + #[test] + fn a_layer_with_a_color_space_keeps_it() { + let layer = CAMetalLayer::new(); + let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap(); + layer.setColorspace(Some(&p3)); + assert!(!tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 }))); + } + + /// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal + /// layers are returned. + #[test] + fn metal_layers_are_found_under_the_view_layer() { + let metal = CAMetalLayer::new(); + assert_eq!(metal_layers(&metal), vec![metal.clone()]); + + let root = CALayer::new(); + assert!(metal_layers(&root).is_empty()); + root.addSublayer(&CALayer::new()); + root.addSublayer(&metal); + assert_eq!(metal_layers(&root), vec![metal.clone()]); + for layer in metal_layers(&root) { + tag_srgb(&layer); + } + assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB }))); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 2cdf80b42..2dc62fb1d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -15,13 +15,6 @@ //! keyboard isn't confined. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] -#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))] -compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)"); -#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))] -compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs"); -#[cfg(all(target_vendor = "win7", not(feature = "windows7")))] -compile_error!("the win7 target requires --no-default-features --features windows7"); - mod automation_args; mod clipboard; #[cfg(feature = "wgpu")] @@ -33,6 +26,8 @@ mod gpu; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; +#[cfg(all(target_os = "macos", feature = "wgpu"))] +mod mac_color_space; #[cfg(target_os = "macos")] mod mac_fonts; #[cfg(target_os = "macos")] @@ -50,7 +45,7 @@ mod open_documents; mod portal_save; mod prefs_dir; mod printing; -#[cfg(all(windows, not(target_vendor = "win7")))] +#[cfg(windows)] mod system_fonts; mod tablet; mod window; @@ -190,7 +185,7 @@ fn main() -> std::process::ExitCode { let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); // Before the first font scan (the app's start): the fonts font services load (#579). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] system_fonts::install(); #[cfg(target_os = "macos")] mac_fonts::install(); @@ -295,7 +290,7 @@ fn main() -> std::process::ExitCode { // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end // is down; the message hook notes each pointer contact and whether it starts with the barrel // button held, and writes the pen's end into the pen feed (`windows_stylus`). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] let options = { use winit::platform::windows::EventLoopBuilderExtWindows as _; let mut options = options; @@ -436,10 +431,6 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } - #[cfg(feature = "windows7")] - { - app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into()); - } app.custom_titlebar = custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); @@ -494,5 +485,28 @@ fn main() -> std::process::ExitCode { } } +/// Tests only: while a test starts another program, the child briefly holds a copy of every file +/// this process has open, until it runs that program. A file lock held through such a copy outlives +/// its own handle, so a GPU start test that released the start file and opened it again found it +/// still locked, as if another copy of the app were running, and failed. A test that starts a +/// program holds [`spawning`]; a test that depends on the start file's lock holds [`no_spawns`]. +#[cfg(all( + test, + any(feature = "wgpu", any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")) +))] +static CHILD_SPAWN: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held while a test starts another program (see [`CHILD_SPAWN`]). +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] +fn spawning() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWN.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Held by a test that releases a locked file and opens it again (see [`CHILD_SPAWN`]). +#[cfg(all(test, feature = "wgpu"))] +fn no_spawns() -> std::sync::RwLockReadGuard<'static, ()> { + CHILD_SPAWN.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(all(test, feature = "wgpu"))] mod tests; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index b6362107e..b6dd3cc8e 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -169,28 +169,37 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option Result, String> { use std::sync::atomic::{AtomicU32, Ordering}; - use zbus::blocking::Proxy; + use zbus::blocking::{MessageIterator, Proxy}; use zbus::zvariant::OwnedObjectPath; const DESKTOP: &str = "org.freedesktop.portal.Desktop"; const REQUEST: &str = "org.freedesktop.portal.Request"; static NEXT: AtomicU32 = AtomicU32::new(0); - let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_"); let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); - let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); - // Listen before asking, so that an answer that comes at once is not missed. - let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?; + // Listen before asking, on every request path: a portal older than version 0.9 ignores + // `handle_token` and answers on the request path it returns, and its answer can come before + // the call's reply. The stream keeps every `Response` from the time it is made. + let rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface(REQUEST)) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?; let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?; // The title is empty, as rfd sends it for a save. - let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; - // A portal older than version 0.9 ignores `handle_token` and answers on the request path it - // returns. - let reply = if handle.as_str() == path { - replies.next() - } else { - Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next() - } - .ok_or("the save dialog closed without an answer")?; + let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + // The answer is the `Response` on the path returned, from the connection that returned it. + let portal = call.header().sender().map(|s| s.as_str().to_string()); + let reply = responses + .filter_map(Result::ok) + .find(|m| { + let header = m.header(); + header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref() + }) + .ok_or("the save dialog closed without an answer")?; let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; answer(code, &results) } @@ -420,20 +429,34 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] type Seen = std::sync::Arc, Option)>>>; - /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` - /// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths` - /// it is a portal older than 0.9, which answers on a request path of its own. `None` when there - /// is no `dbus-daemon` to run. + /// How the mock portal answers `SaveFile`. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + #[derive(Clone, Copy, PartialEq, Eq)] + enum Portal { + /// On the request path that `handle_token` names, after its reply. + Current, + /// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a + /// person answers the dialog. + Old, + /// As `Old`, with the `Response` sent before the reply that names its path. + OldEarly, + } + + /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` + /// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it. + /// `None` when there is no `dbus-daemon` to run. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { use std::io::BufRead as _; use zbus::zvariant::OwnedObjectPath; - let mut daemon = std::process::Command::new("dbus-daemon") + let spawning = crate::spawning(); + let daemon = std::process::Command::new("dbus-daemon") .args(["--session", "--nofork", "--print-address=1"]) .stdout(std::process::Stdio::piped()) - .spawn() - .ok()?; + .spawn(); + drop(spawning); + let mut daemon = daemon.ok()?; let mut address = String::new(); std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; let address = address.trim().to_string(); @@ -441,9 +464,12 @@ mod tests { zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?; let seen = Seen::default(); let log = seen.clone(); + // Made before the client exists: the connection drops a call that comes while no stream + // reads it. + let incoming = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in incoming { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { @@ -460,16 +486,12 @@ mod tests { l.len() }); let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break }; - let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() }; + let token = match portal { + Portal::Current => text("handle_token").unwrap_or_default(), + Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()), + }; let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break }; - if server.reply(&header, &handle).is_err() { - break; - } - if own_paths { - // As a person answers the dialog: after the caller listened on the path returned. - std::thread::sleep(std::time::Duration::from_millis(300)); - } let (code, pick) = script.next().unwrap_or((2, None)); let mut results: HashMap<&str, Value<'_>> = HashMap::new(); if let Some((uri, label)) = pick { @@ -477,7 +499,18 @@ mod tests { results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())]))); } let to = header.sender().cloned().map(zbus::names::BusName::from); - if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() { + let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results)); + if portal == Portal::OldEarly && respond().is_err() { + break; + } + if server.reply(&header, &handle).is_err() { + break; + } + if portal == Portal::Old { + // As a person answers the dialog: after the caller has the path returned. + std::thread::sleep(std::time::Duration::from_millis(300)); + } + if portal != Portal::OldEarly && respond().is_err() { break; } } @@ -486,20 +519,32 @@ mod tests { Some((client, seen, daemon)) } + /// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails + /// the test instead of leaving it waiting. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + let (conn, request) = (conn.clone(), request.clone()); + std::thread::spawn(move || { + let _ = tx.send(save_file(&conn, &request)); + }); + rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds") + } + /// Through a real D-Bus: the call starts the dialog on the first file type with the suggested /// name, and the answer names the file and the type picked; Cancel answers no file. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() { let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)]; - let Some((conn, seen, mut daemon)) = mock_portal(script, false) else { + let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); - let saved = save_file(&conn, &request).unwrap(); + let saved = save_within(&conn, &request).unwrap(); assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]); - assert_eq!(save_file(&conn, &request), Ok(None)); + assert_eq!(save_within(&conn, &request), Ok(None)); let seen = seen.lock().map(|s| s.clone()).unwrap_or_default(); assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone())))); let _ = daemon.kill(); @@ -510,12 +555,28 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn the_answer_is_awaited_on_the_path_the_portal_returns() { - let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else { + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); - assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + let _ = daemon.kill(); + } + + /// A portal older than 0.9 can answer before its reply names the request path: the answer is + /// kept until the path is known, and the next dialog does not take it. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn an_answer_that_comes_before_the_path_is_kept() { + let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)]; + let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let request = request(); + assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + assert_eq!(save_within(&conn, &request), Ok(None)); let _ = daemon.kill(); } diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index 67a5a4a08..56ea30f21 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, fileio, prefs_dir}; /// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), /// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index c9e6539a2..74e05e698 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -1,15 +1,8 @@ //! Desktop printer transport. VectorCraft composes every page and copy before submission; -//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because -//! Windows.Data.Pdf is unavailable there. -#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))] +//! craft-print owns discovery and spooling. use std::process::Command; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; -#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))] -mod windows7; -#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))] -use windows7 as system; - pub struct SystemPrint; impl PrintService for SystemPrint { fn printers(&mut self) -> Vec { @@ -36,9 +29,8 @@ impl PrintService for SystemPrint { } } -#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))] mod system { - use super::*; + use super::{Command, PrintJob, Printer}; pub fn printers() -> Vec { craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect() } @@ -95,7 +87,7 @@ mod system { } } -#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))] +#[cfg(all(test, windows))] mod tests { use super::system::setup_command; use std::ffi::OsStr; diff --git a/apps/vectorcraft/src/printing/windows7.rs b/apps/vectorcraft/src/printing/windows7.rs deleted file mode 100644 index 2d9a6b338..000000000 --- a/apps/vectorcraft/src/printing/windows7.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent. -use std::path::PathBuf; -use std::process::Command; -use vectorcraft_ui_egui::print::{PrintJob, Printer}; - -/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more -/// than an hour old (the print verb reads them after Print returns) are removed first. -fn job_file(pdf: &[u8]) -> Result { - let dir = std::env::temp_dir().join("vectorcraft-print"); - std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?; - for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { - let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600); - if old { - // Another job may still be reading it: leave it for next time. - let _ = std::fs::remove_file(e.path()); - } - } - let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); - let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id())); - // The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never - // truncate an existing path, and remove incomplete files or refused launches immediately. - use std::io::Write as _; - let mut file = - std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?; - if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) { - drop(file); - let _ = std::fs::remove_file(&path); - return Err(format!("can't write {}: {e}", path.display())); - } - Ok(path) -} - -/// Run `c` → its output, or its error message. -fn output(c: &mut Command) -> Result { - let program = c.get_program().to_string_lossy().to_string(); - let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?; - if out.status.success() { - return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); - } - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err }) -} - -/// PowerShell's `\t` lines → the printers. -fn windows_printers(text: &str) -> Vec { - text.lines() - .filter_map(|l| l.trim_end_matches('\r').split_once('\t')) - .filter(|(_, name)| !name.trim().is_empty()) - .map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") }) - .collect() -} - -/// `s` as a PowerShell single-quoted string. -fn ps_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "''")) -} - -fn powershell(script: &str) -> Command { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let mut c = Command::new( - std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into())) - .join("System32") - .join("WindowsPowerShell") - .join("v1.0") - .join("powershell.exe"), - ); - c.args([ - "-NoProfile", - "-NonInteractive", - "-Command", - &format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"), - ]) - .creation_flags(CREATE_NO_WINDOW); - c -} - -pub fn printers() -> Vec { - let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }"; - output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default() -} - -fn submit(job: &PrintJob, file: &std::path::Path) -> Result { - // The PDF viewer's print verb reads the file after this returns: it stays for a while. - let path = ps_quote(&file.to_string_lossy()); - let script = match job.printer { - Some(p) => format!( - "Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden", - ps_quote(&format!("\"{}\"", p.replace('"', ""))) - ), - None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"), - }; - output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})")) -} - -pub fn setup(_printer: Option<&str>) -> Result<(), String> { - let mut command = { - let mut command = Command::new("control.exe"); - command.arg("printers"); - command - }; - command.spawn().map(|_| ()).map_err(|e| e.to_string()) -} - -pub fn print(job: &PrintJob) -> Result { - let file = job_file(job.pdf)?; - let result = submit(job, &file); - if result.is_err() { - // No viewer accepted the launch, so no asynchronous reader needs this document. - let _ = std::fs::remove_file(file); - } - result -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn powershell_lines_and_quotes_are_preserved() { - assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]); - assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'"); - } - #[test] - fn job_files_hold_the_pdf_for_the_async_viewer() { - let path = job_file(b"%PDF-1.7 test").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test"); - std::fs::remove_file(path).unwrap(); - } -} diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 3d538ec88..63c9f7c13 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -1,4 +1,6 @@ -use super::*; +use super::window; +#[cfg(target_os = "linux")] +use super::xwayland; /// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets /// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches @@ -113,8 +115,6 @@ pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { .with_title_shown(false) .with_icon(app_icon()) .with_app_id("ai.storyteller.vectorcraft"), - #[cfg(feature = "windows7")] - renderer: eframe::Renderer::Glow, ..Default::default() } } diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index 814ba55fe..27cb24438 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -16,7 +16,7 @@ //! mouse message that Windows did not make from a pen or a touch //! () //! clears the feed. -#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))] +#![cfg_attr(not(windows), allow(dead_code))] use std::cell::RefCell; use std::sync::OnceLock; @@ -68,7 +68,7 @@ pub fn start() -> StylusFeed { /// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id /// and whether the barrel button was held, and writes the pen's pressure and end into the feed. /// It never consumes the message. -#[cfg(all(windows, not(target_vendor = "win7")))] +#[cfg(windows)] #[allow(unsafe_code)] pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO}; diff --git a/clippy.toml b/clippy.toml index d02d2c40c..6cf0a13be 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,3 +6,4 @@ allow-panic-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index d43e6fa1b..8578d13f1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -169,3 +169,6 @@ sync_github_name = false real_name = "Andrés Ortega Montoya" display_name = "Andrés Ortega Montoya" sync_github_name = false + +[people.krosk93] +sync_github_name = true diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs index 0a48193e2..7ef9fff0b 100644 --- a/crates/affinity/src/model/bounds.rs +++ b/crates/affinity/src/model/bounds.rs @@ -1,5 +1,5 @@ use super::read::MAX_TREE_DEPTH; -use super::*; +use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag}; /// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames /// and tables), in document pixels. diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 755e93745..daf5d778c 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -1,5 +1,8 @@ use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; -use super::*; +use super::{ + Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream, + Tag, paint, stream, +}; /// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). const MAX_PROFILE_BYTES: usize = 32 << 20; diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 422f09d9c..e76e67ba5 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -343,6 +343,44 @@ pub enum PatternFit { Approximate, } +/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile +/// made from the side tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CornerSource { + /// No corner tile: the side tiles run up to the corner. + None, + /// One side tile stretched around the corner and centered on it. + AutoCentered, + /// A side tile on each side reaching into the corner, stretched so the two meet on the + /// corner's diagonal. + AutoBetween, + /// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame. + AutoSliced, + /// A side tile on each side running past the corner, the two overlapping there. + AutoOverlap, +} + +impl CornerSource { + pub const ALL: [CornerSource; 5] = + [CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + + /// The id in a brush definition and in `brush.options` params. + pub fn id(self) -> &'static str { + match self { + CornerSource::None => "none", + CornerSource::AutoCentered => "autoCentered", + CornerSource::AutoBetween => "autoBetween", + CornerSource::AutoSliced => "autoSliced", + CornerSource::AutoOverlap => "autoOverlap", + } + } + + pub fn parse(s: &str) -> Option { + CornerSource::ALL.into_iter().find(|a| a.id() == s) + } +} + /// Pattern brush: tiles repeated along the path, with corner and end tiles. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -352,6 +390,14 @@ pub struct PatternBrush { pub outer_corner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: Option, + /// What the outer corners take when there is no `outer_corner` art. Without art or a source, + /// they take the `inner_corner` art. + #[serde(skip_serializing_if = "Option::is_none")] + pub outer_source: Option, + /// What the inner corners take when there is no `inner_corner` art. Without art or a source, + /// they take the `outer_corner` art. + #[serde(skip_serializing_if = "Option::is_none")] + pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -378,6 +424,8 @@ impl Default for PatternBrush { side: defaults::stitch_tile(), outer_corner: None, inner_corner: None, + outer_source: None, + inner_source: None, start: None, end: None, scale: 100.0, diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 0968f5d6c..884dd27cf 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{BezPath, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; use super::*; @@ -273,15 +273,362 @@ fn pattern_tile_count_by_fit() { fn pattern_places_corner_tiles() { let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); - let corners = out - .iter() - .filter( - |n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0), - ) - .count(); - assert_eq!(corners, 4); + // The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them + // apart. Anchor count no longer does: the side tiles keep their own four anchors now that a + // straight run is not flattened (#3926), where they used to arrive as many-anchor polylines. + let curved = |n: &Node| { + matches!(&n.kind, NodeKind::Path { path, .. } + if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p))) + }; + let corners = out.iter().filter(|n| curved(n)).count(); + assert_eq!(corners, 4, "the four corner ellipses, still curves"); // Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each. assert_eq!(out.len() - corners, 36); + for n in out.iter().filter(|n| !curved(n)) { + let NodeKind::Path { path, .. } = &n.kind else { continue }; + assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors"); + } +} + +/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. +fn auto_corner_brush(outer: Option, inner: Option) -> Brush { + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.outer_source = outer; + p.inner_source = inner; + } + b +} + +const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + +/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every +/// clipping group it is in. +fn covers(n: &Node, q: Point) -> bool { + let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0); + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)), + NodeKind::Path { .. } => inside(n), + _ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))), + } +} + +fn points_of(nodes: &[Node]) -> Vec { + let mut pts = vec![]; + for n in nodes { + all_points(n, &mut pts); + } + pts +} + +/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it. +const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] = + [((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))]; + +/// Whether some point of `pts` is within `tol` of `(x, y)`. +fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool { + pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol) +} + +/// Distance from `p` to the outline of the square (0, 0)–(100, 100). +fn from_square_outline(p: Point) -> f64 { + let dx = (p.x - p.x.clamp(0.0, 100.0)).abs(); + let dy = (p.y - p.y.clamp(0.0, 100.0)).abs(); + if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) } +} + +#[test] +fn pattern_auto_corners_fill_each_corner_of_a_square() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut outputs = vec![]; + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0)); + let pts = points_of(&out); + for (corner, outside) in SQUARE_CORNERS { + assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}"); + } + let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max); + assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}"); + // The band is filled up to each corner along both of its sides. + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for ((cx, cy), _) in SQUARE_CORNERS { + let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum()); + for d in [0.5, 2.5, 4.5, 6.5, 8.5] { + assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})"); + } + } + outputs.push(out); + } + for i in 0..outputs.len() { + for j in i + 1..outputs.len() { + assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]); + } + } +} + +/// A side tile three times as tall as it is long still fills a square's corners without a gap: +/// the two sides meet on the corner's diagonal. +#[test] +fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath(); + for kind in AUTO { + let mut b = auto_corner_brush(Some(kind), Some(kind)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side = rect_art(10.0, 30.0); + } + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] { + let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum()); + for d in [1.0, 5.0, 10.0, 14.0] { + // Along the diagonal, outside and inside the square, and along both sides. + assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d); + } + } + } +} + +/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the +/// middle of the band is filled all the way along the path. +#[test] +fn pattern_auto_corners_join_a_curve_into_the_corner() { + // A straight run into a quarter circle of radius 10, then a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0)); + bp.line_to((-80.0, 10.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let l = track.len(); + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = 0.25; + while s < l - 0.25 { + let q = track.map(s, 0.0); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})"); + s += 0.25; + } + } +} + +/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's +/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end +/// at the diagonal's two ends. +#[test] +fn pattern_auto_corners_after_a_curve_meet_across_the_band() { + // A quarter circle of radius 4 straight into a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0)); + bp.line_to((-80.0, 4.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let corner = track.corner_positions().first().map(|c| c.1).unwrap(); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = corner - 3.0; + while s < corner + 3.0 { + for j in -19..=19 { + let y = j as f64 / 10.0; + let q = track.map(s, y); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})"); + } + s += 0.05; + } + } + // The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the + // lines y = 2 and y = 6, where both Auto-Between halves end at the same point. + let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0)); + for edge in [2.0, 6.0] { + let on_edge: Vec> = out + .iter() + .map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect()) + .collect(); + let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6)))); + assert!(shared, "both halves end at the same point on y = {edge}"); + } +} + +/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does +/// not run past the ends, nor past half the way to the next corner. +#[test] +fn pattern_auto_corners_stay_within_a_short_path() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((2.0, 0.0)); + bp.line_to((2.0, 2.0)); + let mut zigzag = BezPath::new(); + zigzag.move_to((0.0, 0.0)); + for j in 1..=6 { + zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 })); + } + for kind in AUTO { + for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0)); + assert!(!out.is_empty(), "{kind:?}: something is drawn"); + // The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it. + let reach = 2.0 * 3.0; + for p in points_of(&out) { + assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends"); + } + } + } +} + +/// A corner squeezed into a path shorter than the band is wide still fills the band up to the +/// corner's diagonal from both sides, with no gap between the two halves. +#[test] +fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((1.0, 0.0)); + bp.line_to((1.0, 1.0)); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + // The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2). + for f in [0.25, 0.5, 0.75] { + let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f); + for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] { + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}"); + } + } + } +} + +#[test] +fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0)); + assert_eq!(plain.len(), 40, "ten side tiles a side"); + let pts = points_of(&plain); + for (corner, outside) in SQUARE_CORNERS { + assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}"); + } + // A square turns the same way at every corner, so only one of the two kinds of corner tile + // draws on it; the other leaves the stroke as it is without automatic corners. + let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0)); + let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0)); + assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square"); +} + +#[test] +fn pattern_corner_art_wins_over_corner_sources() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let saved = pattern_brush(PatternFit::Stretch, true); + let mut with_auto = saved.clone(); + if let BrushKind::Pattern(p) = &mut with_auto.kind { + p.outer_source = Some(CornerSource::AutoSliced); + p.inner_source = Some(CornerSource::None); + } + assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0))); +} + +/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its +/// source is None. +#[test] +fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0)); + let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None))); + let without = |outer: bool, source: Option| { + let mut b = pattern_brush(PatternFit::Stretch, true); + if let BrushKind::Pattern(p) = &mut b.kind { + if outer { + (p.outer_corner, p.outer_source) = (None, source); + } else { + (p.inner_corner, p.inner_source) = (None, source); + } + } + draw(&b) + }; + assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in"); + let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None))); + assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); +} + +#[test] +fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() { + let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap(); + assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}"); + let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (None, None)); + let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap(); + assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none"))); + let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None))); +} + +/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a +/// tile's outline gains no stroke along the cut. +#[test] +fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + for g in clips { + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 2); + assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing"); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let tile = kids[1].path_data().unwrap(); + assert!( + tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0), + "the tile keeps its outline past the diagonal, where the mask hides it" + ); + } +} + +/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along, +/// but not its stroke: the mask still takes in the stroke past the tile's far end. +#[test] +fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + let center = Point::new(4.0, 4.0); + for g in clips { + let kids = g.children().unwrap(); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let half = kids[1].appearance.stroke_width() / 2.0; + assert!(half > 0.0); + let tile = kids[1].path_data().unwrap(); + let anchors: Vec = tile.anchors().map(|(_, _, a)| a.p).collect(); + let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64); + // The tile's corner, and its diagonal through the square's center; each mask keeps one + // side of the diagonal. + let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)] + .map(|(x, y)| Point::new(x, y)) + .into_iter() + .min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid))) + .unwrap(); + let cross = |q: Point| (q - p).cross(center - p); + let side = cross(mid).signum(); + for a in &anchors { + // The points of the stroke round the anchor, along and across the tile's leg. + for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9; + if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 { + assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})"); + } + } + } + } } #[test] @@ -800,19 +1147,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() { let a1 = anchors(&one); assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}"); - // Sixteen tracks would be sixteen times that without a total budget. - // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the - // budget must not bite before the per-factor caps have actually multiplied past it. - let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0)); - assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece"); - assert_eq!(anchors(&four), 4 * a1); + // Two tracks are well inside the budget, so nothing is cut: it must not bite before the + // per-factor caps have actually multiplied past it. + let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0)); + assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece"); + assert_eq!(anchors(&two), 2 * a1); - // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps. - let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0)); + // Enough tracks that the per-run caps alone would pass the budget. Derived from what one + // track costs rather than written down, because how many anchors a tile holds is not fixed: + // a tile on a straight run keeps its own anchors instead of being flattened (#3926), which + // moved this by a factor of four. + let tracks = 2_000_000 / a1 + 2; + let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0)); let an = anchors(&many); - assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}"); - assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}"); - assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); + assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}"); + assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}"); + assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); } /// The same for scatter, whose per-track guard is also 20,000 copies. @@ -843,3 +1193,94 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() { assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget"); assert!(anchors(&out) < 2_000_000); } + +/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve). +fn anchors_and_curved(n: &Node) -> (usize, usize) { + let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) }; + let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect(); + (all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count()) +} + +/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the +/// tile's curves survive. They used to be flattened into chords by the bending code, which is +/// visible in the stroke itself and leaves no handles after Expand Brush Strokes. +#[test] +fn a_pattern_tile_keeps_its_curves_on_a_straight_path() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let (tile_anchors, tile_curved) = anchors_and_curved(&circle); + assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle"); + + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty(), "the brush drew something"); + for n in &out { + assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon"); + } + // The ink is in the same place: a circle tile still covers the path it was laid along. + let b = bounds(&out); + assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width()); + assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height()); +} + +/// The other half of the same behaviour: where the path really does bend, the tile is still +/// subdivided, because an affine cannot reproduce a bend. The fit is checked before it is used, +/// so a bent run falls back to exactly what it did before. +#[test] +fn a_pattern_tile_on_a_bent_path_is_still_subdivided() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + // A circle to run along: every tile sits on a curving section. + let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0)); + assert!(!out.is_empty()); + let bent = out.iter().filter(|n| anchors_and_curved(n).0 > 8).count(); + assert!(bent > out.len() / 2, "tiles on a bend are still densified: {bent} of {}", out.len()); +} + +/// The kind of every node in a piece of brushed art, in paint order. +fn kinds(n: &Node) -> Vec<&'static str> { + let mut out = vec![match &n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Group { .. } => "group", + NodeKind::SymbolInstance { .. } => "symbol", + _ => "other", + }]; + for c in n.children().into_iter().flatten() { + out.extend(kinds(c)); + } + out +} + +/// The straight-run route differs from the bending route in the tile's curves and in nothing else, +/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a +/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run +/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched. +#[test] +fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]); + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + let doc = Document::new(1.0, 1.0); + let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0)); + let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0)); + assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something"); + for (what, out) in [("a straight run", &straight), ("a bend", &bent)] { + for n in out { + assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance"); + } + } +} diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 85ccbd990..7d8799186 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 { if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l } } +/// Whether two unit vectors point the same way, to within the slack a unit vector carries after +/// a few multiplications (a bend of even a thousandth of a degree fails this). +fn aligned(a: Vec2, b: Vec2) -> bool { + a.dot(b) > 1.0 - 1e-12 +} + /// Left-hand normal in y-down document space (for a path running +x it points +y). pub fn normal(t: Vec2) -> Vec2 { Vec2::new(-t.y, t.x) @@ -154,6 +160,59 @@ impl Track { let (p, t) = self.frame(s); p + normal(t) * d } + + /// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping + /// through it is an affine of the art and nothing more. + /// + /// Read off the track, not sampled. A bend in the span is two segments with different + /// directions, however short the bent part is, so this cannot miss one; a sampled check can, + /// because a wiggle narrower than the sample spacing passes between the samples (#3926). + /// + /// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a + /// non-finite bound, a track with no segments) is reported as not straight, and the caller + /// bends it the long way. + pub fn is_straight(&self, s0: f64, s1: f64) -> bool { + // Checked before the min/max, which quietly drop a NaN rather than propagate it. + let n = self.segments(); + if !(s0.is_finite() && s1.is_finite()) || n == 0 { + return false; + } + let (lo, hi) = (s0.min(s1), s0.max(s1)); + let l = self.len(); + // A closed track wraps, so a span reaching past either end covers segments twice. + if self.closed && (lo < -1e-9 || hi > l + 1e-9) { + return false; + } + // Past an open track's ends `frame` extrapolates along the end segment's direction, so + // those segments are part of the span too. + let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)]; + let touched = (0..n).filter(|&i| { + let (a, b) = (self.cum.get(i), self.cum.get(i + 1)); + matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9) + }); + let mut dir: Option = None; + let mut any = false; + for i in touched.chain(ends.into_iter().flatten()) { + let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) = + (self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1)) + else { + return false; + }; + if dir.is_some_and(|first: Vec2| !aligned(first, d)) { + return false; + } + dir = Some(d); + any = true; + // `frame` blends the two vertex tangents across the segment unless the vertex is a + // corner, where it uses the segment's own direction. So a neighbour outside the span + // can still bend the tangent inside it, and checking the directions alone would miss + // that: segments d, d, e leave vertex 2's tangent at unit(d + e). + if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) { + return false; + } + } + any + } } /// The most flattened elements of a path kept. @@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 { mod tests { use super::*; + /// A straight run is straight; a bend in the span is not, however short it is. This is the + /// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review). + #[test] + fn straightness_is_read_off_the_track_not_sampled() { + let line = |pts: &[(f64, f64)], closed: bool| { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for p in &pts[1..] { + bp.line_to(*p); + } + if closed { + bp.close_path(); + } + tracks(&bp, 0.1).remove(0) + }; + + let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false); + assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run"); + // Open tracks extrapolate along the end directions, which is still the same line. + assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends"); + + // A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code + // would choose, and still a bend. + let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false); + assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend"); + assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it"); + assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight"); + + // `frame` blends vertex tangents, so the segment before a bend is not straight all the way + // to the vertex: the tangent there is already turning. Pinning this because checking the + // segment directions alone would call that span straight and be wrong. + let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false); + assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex"); + assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight"); + + // Fails closed where it does not reason: a closed track's seam, and nonsense bounds. + let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused"); + assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight"); + assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused"); + } #[test] fn straight_track_frames() { let mut bp = BezPath::new(); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index d20fe5700..95632ad49 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -6,11 +6,11 @@ use std::sync::Arc; -use vectorcraft_doc::{Node, NodeKind, PressureProfile}; -use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile}; +use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath, Vec2}; -use crate::track::{Rng, Track, seed_of, tracks}; -use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; +use crate::track::{Rng, Track, normal, seed_of, tracks}; +use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; /// Longest mapped segment (points in document space). const MAX_SEG: f64 = 1.5; @@ -31,6 +31,8 @@ const MAX_SUB: usize = 4000; const MAX_ANCHORS: usize = 2_000_000; /// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget. const MAX_WALK: usize = 10_000; +/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with. +const MASK_STEPS: usize = 32; /// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and /// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by @@ -153,12 +155,111 @@ impl AlongMap { let w = self.xs[i + 1] - self.xs[i]; if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w } } + /// Whether `at` is one linear function across the art, so it adds no bend of its own. + /// + /// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one + /// spans everything. Between Guides is not, once its three pieces really do differ, which is + /// the whole point of it. + fn is_linear(&self) -> bool { + let mut slope: Option = None; + for i in 0..3 { + // A piece with no width constrains nothing: no art x lands inside it. + if self.xs[i + 1] - self.xs[i] <= 1e-12 { + continue; + } + let s = self.slope(i); + if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) { + return false; + } + slope = Some(s); + } + slope.is_some() + } + + /// The arc lengths the art spans, low to high. + fn span(&self) -> (f64, f64) { + (self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3])) + } + /// Largest along-scale (arc length per art unit) of the pieces. fn max_scale(&self) -> f64 { (0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max) } } +/// The affine that reproduces `f` over `b` to within `tol`, if one does. +/// +/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the +/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it +/// into chords throws its curves away for nothing (#3926). +/// +/// Derived from three corners and then checked against a grid. The check is a second opinion, not +/// the gate: `bend` only calls this once the track and the along map have been read and found +/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review). +fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { + let (w, h) = (b.width(), b.height()); + if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) { + return None; + } + let origin = Point::new(b.x0, b.y0); + let q0 = f(origin); + // Images of the unit x and y directions. + let u = (f(Point::new(b.x1, b.y0)) - q0) / w; + let v = (f(Point::new(b.x0, b.y1)) - q0) / h; + if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) { + return None; + } + let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2()); + // Five by five including the corners the fit came from, so a map that merely agrees at those + // three is rejected. + for i in 0..=4 { + for j in 0..=4 { + let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0); + let (want, got) = (f(p), a * p); + if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol { + return None; + } + } + } + Some(a) +} + +/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its +/// curves survive. +/// +/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between +/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every +/// path, an empty path or a group left with no children is dropped, and strokes are scaled by +/// `width_scale` at every level rather than by the transform's own factor. +fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option { + let mut out = prepared(n, width_scale)?; + out.transform(a, false); + Some(out) +} + +/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform. +fn prepared(n: &Node, width_scale: f64) -> Option { + let mut out = n.clone(); + out.appearance.scale_strokes(width_scale); + match &mut out.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + if path.is_empty() { + return None; + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => { + let kids: Vec> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect(); + if kids.is_empty() { + return None; + } + *children = kids; + } + _ => return None, + } + Some(out) +} + /// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`. fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option { let cy = b.center().y; @@ -166,6 +267,20 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option Opti bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross) } +/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what +/// [`CornerSource`] gives. +#[derive(Clone, Copy)] +enum Corner<'a> { + Art(&'a (Node, Rect)), + Source(CornerSource), +} + +/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented, +/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover, +/// which is at most `room`. +/// +/// At the corner, the pieces run straight along the directions the path arrives at and leaves +/// the corner in, and towards their far ends they move onto the path itself, so they meet the side +/// tiles where the path curves. The corner's diagonal is the line through the corner that halves +/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of +/// nearly 180° get no pieces. +fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec, f64) { + let (art, b) = side; + let (a_dir, b_dir) = t.in_out(i); + let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) }; + let bt = a_dir + b_dir; + if source == CornerSource::None || a_dir.dot(bt) < 1e-3 { + return (vec![], 0.0); + } + let (tw, r) = (b.width() * k, b.height() * k / 2.0); + let (x0, cy) = (b.x0, b.center().y); + // Art point → (distance along from the left edge, offset across), in points. + let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k); + // How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal. + let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt); + // `u` along `d` from the corner and `y` across, moved from the straight line onto the path as + // `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or + // a point between it and `far`). + let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| { + let straight = p + d * u + normal(d) * y; + let span = far - from; + let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 }; + if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight } + }; + // The most the diagonal is ahead of the corner, along either direction, within the band. Each + // source stretches the side tile along to `len` where the band is too tall for the tile to + // reach the diagonal, so the two sides always meet on it. + let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt); + // Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto + // [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the + // points on the diagonal stay on the straight lines, where the two sides meet. + let into = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(a_dir, y).clamp(-reach, reach); + on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len) + }; + let out_of = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(b_dir, y).clamp(-reach, reach); + on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len) + }; + let flat_tol = tolerance(b.height().max(b.width()) * k) / k; + // The tile is squeezed along so that it covers at most `room` of the path on each side. + let room = room.max(0.0); + let len = tw.max(2.0 * ahead).min(match source { + CornerSource::AutoCentered => 2.0 * room, + CornerSource::AutoBetween => room, + _ => room + ahead, + }); + if len <= 1e-9 { + return (vec![], 0.0); + } + let s = if tw > 1e-9 { len / tw } else { 1.0 }; + let max_du = MAX_SEG / (2.0 * k * s.max(1.0)); + let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, k); + // Art point → (distance along from the left edge, stretched to `len`, and offset across). + let along = move |q: Point| { + let (x, y) = local(q); + (x * s, y) + }; + match source { + CornerSource::None => (vec![], 0.0), + CornerSource::AutoCentered => { + let half = len / 2.0; + let f = move |q: Point| { + let (x, y) = along(q); + let u = x - half; + if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) } + }; + (warp(&f).into_iter().collect(), half) + } + CornerSource::AutoBetween => { + let before = move |q: Point| { + let (x, y) = along(q); + into(x - len, y, len) + }; + let after = move |q: Point| { + let (x, y) = along(q); + out_of(x, y, len) + }; + (warp(&before).into_iter().chain(warp(&after)).collect(), len) + } + CornerSource::AutoSliced | CornerSource::AutoOverlap => { + // Each side's tile runs `ahead` past the corner. + let far = len - ahead; + let before = move |q: Point| { + let (x, y) = along(q); + on_path(a_dir, x - far, y, 0.0, -far) + }; + let after = move |q: Point| { + let (x, y) = along(q); + on_path(b_dir, x - ahead, y, 0.0, far) + }; + let (first, second) = (warp(&before), warp(&after)); + if source == CornerSource::AutoOverlap { + return (first.into_iter().chain(second).collect(), far); + } + // Auto-Sliced masks each tile by the part of the outline of the box round its art + // (strokes included), placed as the tile is, on its own side of the diagonal. A tile + // squeezed along keeps its stroke width, so the box's margins at its ends grow by as + // much as the tile is squeezed. + let vb = art.visual_bounds().unwrap_or(*b); + let grow = 1.0 / s.min(1.0); + let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow); + let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y)); + let outline = |f: &dyn Fn(Point) -> Point| -> Vec { + let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64); + corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect() + }; + let pieces = + first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt))); + (pieces.collect(), far) + } + } +} + +/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through +/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side. +fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { + let side = |q: Point| (q - p).dot(towards); + let mut kept = vec![]; + for (j, &a) in quad.iter().enumerate() { + let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a); + let (da, db) = (side(a), side(b)); + if da <= 0.0 { + kept.push(a); + } + if (da <= 0.0) != (db <= 0.0) { + kept.push(a.lerp(b, da / (da - db))); + } + } + if kept.len() < 3 { + return None; + } + let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default()); + Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true })) +} + pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across); let Some(side) = orient(&pb.side) else { return vec![] }; @@ -413,6 +682,9 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let end = pb.end.as_ref().and_then(orient); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; + // Each kind of corner takes its own art, else its source, else the other kind's art. + let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source)); + let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source)); let mut out = Budget::default(); for t in tracks(bp, 0.05) { if out.full() { @@ -423,13 +695,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec continue; } let corners = t.corner_positions(); - // Corner tiles, centred on the corner and turned to the bisector. + // Corner tiles, centred on the corner and turned to the bisector. The path on each side of + // a corner is shortened by `trims` (`None`: half the widest corner tile art). let mut corner_half = 0.0_f64; - for &(i, _) in &corners { + let mut trims: Vec> = Vec::with_capacity(corners.len()); + // The path a tile made at corner `ci` may cover on each side: up to the ends of an open + // track, and half the way to the next corner. + let room = |ci: usize| { + let at = |j: usize| corners.get(j).map(|c| c.1); + let s = at(ci).unwrap_or(0.0); + let last = corners.len().saturating_sub(1); + let before = match (ci.checked_sub(1).and_then(at), t.closed) { + (Some(prev), _) => (s - prev) / 2.0, + (None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0, + (None, false) => s, + }; + let after = match (at(ci + 1), t.closed) { + (Some(next), _) => (next - s) / 2.0, + (None, true) => (at(0).unwrap_or(s) + l - s) / 2.0, + (None, false) => l - s, + }; + before.min(after) + }; + for (ci, &(i, _)) in corners.iter().enumerate() { + if out.full() { + break; + } let (a, b) = t.in_out(i); // Turning towards the left normal puts the tile's +y side on the inside. - let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) }; - let Some((art, bb)) = tile else { continue }; + let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) }; + let (art, bb) = match tile { + Some(Corner::Art(tile)) => tile, + Some(Corner::Source(source)) => { + let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci)); + for n in pieces { + out.take(Some(n)); + } + trims.push(Some(trim)); + continue; + } + None => { + trims.push(Some(0.0)); + continue; + } + }; + trims.push(None); corner_half = corner_half.max(bb.width() * k / 2.0); let bis = a + b; let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() }; @@ -441,30 +751,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec n.transform(xf, true); out.take(Some(n)); } - // Runs between corners (closed tracks wrap past the end). + let trim = |c: Option| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half)); + // Runs between corners (closed tracks wrap past the end), with the corners they start and + // end at. let mut bounds: Vec = corners.iter().map(|c| c.1).collect(); - let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() { - vec![(0.0, l, false, false)] + let nc = bounds.len(); + let runs: Vec<(f64, f64, Option, Option)> = if bounds.is_empty() { + vec![(0.0, l, None, None)] } else if t.closed { let first = bounds[0]; bounds.push(first + l); - bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect() + bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect() } else { let mut v = vec![0.0]; v.extend(bounds); v.push(l); - let n = v.len(); - v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect() + v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect() }; let nruns = runs.len(); - let corner_tiles = inner.is_some() || outer.is_some(); for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() { - if ca && corner_tiles { - a += corner_half; - } - if cb && corner_tiles { - b -= corner_half; + if out.full() { + break; } + a += trim(ca); + b -= trim(cb); if !t.closed && ri == 0 && let Some(st) = &start @@ -532,3 +842,83 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec } out.out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::track::tracks; + + /// #3926 review: the five by five grid cannot promise a run is straight, only that it looks + /// straight where it was asked. A zigzag whose vertices land on the sample positions is + /// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all + /// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off. + /// + /// This is why the gate is `Track::is_straight` and the grid is only a second opinion. + #[test] + fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() { + // Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the + // vertices are not corners and their tangents blend flat, which is what hides them. + let (d, a) = (20.0, 4.0); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 1..=16 { + bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 })); + } + let t = tracks(&bp, 0.01).remove(0); + assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended"); + + // A span over four full periods, held clear of both ends so no sample hits the + // extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0. + let seglen = (d * d + a * a).sqrt(); + let (s0, s1) = (4.0 * seglen, 12.0 * seglen); + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let along = AlongMap::linear(b.width(), s0, s1); + let (cy, x0, cross) = (b.center().y, b.x0, 1.0); + let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross); + let scale = along.max_scale().max(cross.abs()).max(1e-6); + let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale; + + // The grid is satisfied, and it should not be. + let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend"); + + // How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches. + let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width(); + let at_peak = Point::new(peak_x, cy); + let off = (f(at_peak) - fit * at_peak).hypot(); + assert!( + off > 3.0 && off > flat_tol * 20.0, + "the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance" + ); + + // Reading the track instead refuses it, and still admits the straight run it is made of. + assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend"); + let straight = tracks( + &{ + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((320.0, 0.0)); + bp + }, + 0.01, + ) + .remove(0); + assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight"); + + // End to end through `bend`: the aliased bend is subdivided, the straight run keeps its + // curves. This is the gate itself, not the two predicates it is made of. + let anchors = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(), + _ => 0, + }; + let circle = Node::path( + vectorcraft_doc::NodeId(0), + vectorcraft_geom::shapes::ellipse(b), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with"); + let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something"); + assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent)); + let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something"); + assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves"); + } +} diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 8733c96c8..0da950ead 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -66,7 +66,7 @@ pub(crate) fn number(s: &str) -> Option { /// A whole number; some writers put integers as `1.0`. pub(crate) fn integer(s: &str) -> Option { let s = s.trim(); - s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64)) + s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64)) } /// One object: its type (the value of its `0` group) and the groups up to the next object. @@ -456,3 +456,14 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti } std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect() } + +#[cfg(test)] +mod tests { + use super::integer; + + #[test] + fn integer_rejects_fractional_float_values() { + assert_eq!(integer("1.0"), Some(1)); + assert_eq!(integer("1.9"), None); + } +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 1e7baac26..7ab1d17f8 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -126,6 +126,61 @@ fn circular_subpath(sub: &Sub) -> Option { if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } + // Verify that the curve progresses monotonically around the circle without reversing, + // both along each cubic segment's angular derivative and across sampled intervals, + // and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn. + let mut total_turn = 0.0; + let mut dir: Option = None; + let ang_threshold = radius * radius * 1e-9; + let mut seg_from = sub.start; + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + for step in 0..=32 { + let t = f64::from(step) / 32.0; + let p = cubic_point(seg_from, a, b, to, t); + let u = 1.0 - t; + let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x); + let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y); + let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx; + if cross.abs() > ang_threshold { + let is_ccw = cross > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + } + } + seg_from = to; + } + for w in samples.windows(2) { + let (p0, p1) = (w[0], w[1]); + let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y); + let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y); + let cross = v0x * v1y - v0y * v1x; + let dot = v0x * v1x + v0y * v1y; + let d_theta = cross.atan2(dot); + if d_theta.abs() > 1e-9 { + let is_ccw = d_theta > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + total_turn += d_theta; + } + } + if sub.closed { + if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 { + return None; + } + } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { + return None; + } Some(CircularSub { centre, radius, samples }) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 46b09f5ff..30a8d1a81 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -246,6 +246,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); } +#[test] +fn a_closed_sliver_along_a_circle_is_not_a_circle() { + let d = doc_with(|d| { + let (cx, cy, r) = (60.0, 250.0, 200.0); + let at = |deg: f64| { + let rad = deg.to_radians(); + Point::new(cx + r * rad.cos(), cy + r * rad.sin()) + }; + let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| { + let p0 = at(deg0); + let p3 = at(deg1); + let step = (deg1 - deg0).to_radians(); + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let r0 = deg0.to_radians(); + let r1 = deg1.to_radians(); + let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k); + let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k); + sliver.curve_to(p1, p2, p3); + }; + let mut sliver = vectorcraft_geom::BezPath::new(); + sliver.move_to(at(-110.0)); + along(&mut sliver, -110.0, -90.0); + along(&mut sliver, -90.0, -70.0); + along(&mut sliver, -70.0, -110.0); + sliver.close_path(); + vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + + let modern = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&modern), ["CIRCLE"]); + assert_eq!(kinds(&modern), ["SPLINE"]); +} + +#[test] +fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() { + let d = doc_with(|d| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(-8.404028665134, 62.061475842818)); + b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0)); + vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let dxf = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&dxf), ["ARC"]); + assert_eq!(kinds(&dxf), ["SPLINE"]); +} + #[test] fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() { let cases = [(true, true), (true, false), (false, false), (false, true)]; diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 818a5fbe4..e88f767a5 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -69,7 +69,7 @@ impl fmt::Display for ColorKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Components are hundredths of the written units: exact with two decimals. let num = |q: i32| { - let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100); + let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.unsigned_abs() / 100, q.unsigned_abs() % 100); s.trim_end_matches('0').trim_end_matches('.').to_string() }; let [a, b, c, k] = self.v.map(num); @@ -461,6 +461,14 @@ impl Rng { mod tests { use super::*; + #[test] + fn key_display_survives_saturated_lab() { + for text in ["lab 50 -1e20 0", "lab 50 0 -1e20", "lab 50 1e20 0"] { + let k = ColorKey::parse(text).unwrap(); + assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{text}"); + } + } + fn close(a: Color, b: Color) -> bool { let v = |c: Color| match c { Color::Cmyk { c, m, y, k } => vec![c, m, y, k], diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 6103e7dfd..dd5c41325 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -1,6 +1,6 @@ use super::profile::{PressureProfile, WidthProfile}; use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; -use super::*; +use super::{BlendMode, Deserialize, Paint, Serialize}; /// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 56df51ef6..ccae9666b 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), /// in order along the path. Two points at the same position make a discontinuous point: the diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 3e6a29a32..acd4d8189 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum LineCap { diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index bdd20f4c8..b78b8eb01 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 7bf88ac16..69ad12781 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1059,13 +1059,146 @@ pub fn steps_knockout_shows(steps: &[Arc]) -> bool { !steps.iter().all(|c| paints_opaquely(c)) } -/// Evaluate a blend: the keys and generated steps in paint order. +/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included. +/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps +/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps +/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes +/// proportionally fewer steps between each pair of keys ([`fit_steps`]). +pub const MAX_BLEND_STEPS: usize = 100_000; + +/// The steps between each pair of keys, as the blend's spacing asks for them. +fn requested_steps(keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> Vec { + keys.windows(2) + .enumerate() + .map(|(i, w)| match w { + [a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))), + _ => 0, + }) + .collect() +} + +/// Most steps `spacing` makes between two keys, whatever their size and distance +/// ([`blend_step_count`]'s clamps). +fn most_steps(spacing: BlendSpacing) -> usize { + match spacing { + BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, + BlendSpacing::Distance(_) => 1000, + BlendSpacing::SmoothColor => 256, + } +} + +/// `want` steps between each pair of keys, fewer where they would make more than +/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by +/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share +/// of its steps, rounded down, and none when the keys alone fill the budget. +fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec { + let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128; + let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add); + if demand <= room { + return want.to_vec(); + } + // `demand` is more than `room`, so it isn't 0, and each share is below `n`. + want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect() +} + +/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its +/// address while the count runs. +#[derive(Default)] +struct StepCount { + known: std::collections::HashMap<(usize, bool), usize>, +} + +impl StepCount { + /// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`]. + /// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced + /// by distance (or by distance where the colours match) makes more steps scaled up, so a + /// copy counts the most its spacing can make between each pair, an upper bound for any scale. + fn node(&mut self, n: &Node, copy: bool) -> usize { + let at = (std::ptr::from_ref(n) as usize, copy); + if let Some(&known) = self.known.get(&at) { + return known; + } + let count = match &n.kind { + NodeKind::Blend { children, spec } if copy => self.most(children, spec), + NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1, + // Each instance draws the source again. + NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)), + _ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy), + }; + self.known.insert(at, count); + count + } + + fn nodes(&mut self, nodes: &[Arc], copy: bool) -> usize { + nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add) + } + + /// What one step between keys `a` and `b` makes: itself and the steps of the blends in it. + /// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key. + fn step(&mut self, a: &Node, b: &Node) -> usize { + let inner = match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) + if k1 == k2 && !(*k1 && ca.len() != cb.len()) => + { + (0..ca.len().max(cb.len())) + .map(|i| match (ca.get(i), cb.get(i)) { + (Some(x), Some(y)) => self.step(x, y).saturating_sub(1), + (Some(x), None) | (None, Some(x)) => self.node(x, true), + (None, None) => 0, + }) + .fold(0, usize::saturating_add) + } + _ => self.node(a, true).max(self.node(b, true)), + }; + inner.saturating_add(1) + } + + /// What each step between each pair of `keys` makes ([`Self::step`]). + fn steps(&mut self, keys: &[Arc]) -> Vec { + keys.windows(2) + .map(|w| match w { + [a, b] => self.step(a, b), + _ => 1, + }) + .collect() + } + + /// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes + /// in all, its keys' included. + fn blend(&mut self, keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec, usize) { + let inside = self.nodes(keys, false); + let each = self.steps(keys); + let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside); + let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add); + (kept, total) + } + + /// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts + /// with [`most_steps`] between each pair, before rounding. + fn most(&mut self, keys: &[Arc], spec: &BlendSpec) -> usize { + let inside = self.nodes(keys, true); + let each = self.steps(keys); + let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add); + inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside))) + } +} + +/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the +/// spacing asks for, fewer when they and the steps of the blends nested in them would pass +/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these). +pub fn blend_steps(keys: &[Arc], spec: &BlendSpec) -> Vec { + StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0 +} + +/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to +/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]). pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let k = keys.len(); if k < 2 { return keys.iter().map(|n| (**n).clone()).collect(); } let rail = Rail::new(keys, spec); + let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref()); let mut out = Vec::new(); for (i, a) in keys.iter().enumerate() { let mut key = (**a).clone(); @@ -1074,8 +1207,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { } out.push(key); let Some(b) = keys.get(i + 1) else { break }; - let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i)); - let n = blend_step_count(a, b, spec.spacing, len); + let n = counts.get(i).copied().unwrap_or(0); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. @@ -1301,4 +1433,128 @@ mod tests { mid.walk(&mut |n| ids.push(n.id)); assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}"); } + + /// A blend of `a` and `b` spaced by `spacing`. + fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node { + let spec = BlendSpec { spacing, ..Default::default() }; + Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) }) + } + + /// A blend's keys and options. + fn blend_parts(n: &Node) -> Option<(&Vec>, &BlendSpec)> { + match &n.kind { + NodeKind::Blend { children, spec } => Some((children, spec)), + _ => None, + } + } + + /// A square of side `side` at (`x`, `y`). + fn square(x: f64, y: f64, side: f64) -> Node { + Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default()) + } + + /// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input). + fn nested(levels: u32, x: f64) -> Node { + if levels == 0 { + return square(x, 0.0, 4.0); + } + let w = 40.0 * f64::from(levels); + blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15)) + } + + /// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at + /// a time, the blends in its keys and steps evaluated again. + fn drawn_steps(n: &Node) -> usize { + match &n.kind { + NodeKind::Blend { children, spec } => { + let out = blend_expand(children, spec); + out.len() - children.len() + out.iter().map(drawn_steps).sum::() + } + _ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()), + } + } + + /// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps, + /// and again the steps of the level below in each key and each step. + fn nested_steps(levels: u32) -> usize { + (17usize.pow(levels) - 1) * 15 / 16 + } + + /// #3579: blends whose keys are blends draw their steps again in every step above them. Five + /// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps + /// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each + /// level past that adds only what its keys draw. + #[test] + fn nested_blends_keep_to_the_step_budget() { + assert_eq!(nested_steps(5), 1_331_115); + let four = nested_steps(4); + assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS); + let five = nested(5, 0.0); + let (children, spec) = blend_parts(&five).unwrap(); + assert_eq!(blend_steps(children, spec), vec![0]); + assert_eq!(drawn_steps(&five), 2 * four); + assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four); + // A blend whose keys leave room keeps the same share of its steps between each pair. + let mut three = nested(3, 0.0); + if let NodeKind::Blend { spec, .. } = &mut three.kind { + spec.spacing = BlendSpacing::Steps(1000); + } + let (children, spec) = blend_parts(&three).unwrap(); + let inside = 2 * nested_steps(2); + let each = 1 + nested_steps(2); + let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each); + assert_eq!(blend_steps(children, spec), vec![kept]); + let drawn = drawn_steps(&three); + assert_eq!(drawn, inside + kept * each); + assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}"); + } + + /// Nested blends within the budget make every step their spacing asks for, as before. + #[test] + fn nested_blends_within_the_budget_draw_every_step() { + for levels in 1..=4 { + let n = nested(levels, 0.0); + let (children, spec) = blend_parts(&n).unwrap(); + assert_eq!(blend_steps(children, spec), vec![15]); + assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}"); + } + // The keys, then 15 steps, then the last key, each a blend whose keys moved with it. + let two = nested(2, 0.0); + let (children, spec) = blend_parts(&two).unwrap(); + let out = blend_expand(children, spec); + assert_eq!(out.len(), 17); + assert_eq!(out[0], *children[0]); + assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. }))); + assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0))); + } + + /// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the + /// most its spacing can make. Here a tiny blend of one step is blended with a large blend of + /// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000 + /// steps each, which neither key's own count shows. + #[test] + fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() { + let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0)); + let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1)); + assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1)); + let n = blend_of(tiny, large, BlendSpacing::Steps(1000)); + let (children, spec) = blend_parts(&n).unwrap(); + let steps = blend_steps(children, spec); + assert!(steps[0] < 1000, "{steps:?}"); + let drawn = drawn_steps(&n); + assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}"); + } + + /// A flat blend of many keys shares the budget evenly between its pairs. + #[test] + fn many_keys_share_the_step_budget() { + let keys: Vec> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect(); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }; + let steps = blend_steps(&keys, &spec); + assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]); + assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::()); + // Within the budget, every step stays. + assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]); + assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]); + } } diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs new file mode 100644 index 000000000..cf85507c7 --- /dev/null +++ b/crates/doc/src/canvas_range.rs @@ -0,0 +1,328 @@ +//! A read document brought within the canvas: what lies beyond [`MAX_COORD`] is removed (#3575). +//! Edits refuse to leave anything there (`doc_sane` in the engine checks the artboards, the guides, +//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be +//! opened but not edited: one object, artboard or guide out there made every move fail. + +use std::sync::Arc; + +use vectorcraft_geom::{Point, Rect}; + +use crate::{Document, MAX_COORD, Node, NodeId, NodeKind}; + +/// What [`Document::drop_beyond_canvas`] removed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BeyondCanvas { + /// Objects removed whole (a layer or group keeps its children within the canvas). + pub objects: usize, + pub artboards: usize, + /// Guides beyond the canvas, and the artboard guides of the artboards removed. + pub guides: usize, + /// The ruler origin was beyond the canvas and is back at the origin. + pub ruler_origin: bool, +} + +impl BeyondCanvas { + /// The warning for what was removed, or `None` when nothing was. + pub fn note(&self) -> Option { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {many}")), + }; + let parts: Vec = + [count(self.objects, "object", "objects"), count(self.artboards, "artboard", "artboards"), count(self.guides, "guide", "guides")] + .into_iter() + .flatten() + .collect(); + let limit = format!("more than {MAX_COORD} pt from the origin"); + match (parts.is_empty(), self.ruler_origin) { + (true, false) => None, + (true, true) => Some(format!("the ruler origin lay beyond the canvas ({limit}) and was reset")), + (false, origin) => Some(format!( + "{} beyond the canvas ({limit}) {} removed{}", + parts.join(", "), + if self.objects + self.artboards + self.guides == 1 { "was" } else { "were" }, + if origin { "; the ruler origin, beyond it too, was reset" } else { "" } + )), + } + } +} + +/// Is every side of `r` finite and within [`MAX_COORD`] of the origin? +fn rect_in_range(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| in_range(*v)) +} + +fn in_range(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +/// Does `n` reach beyond the canvas? An object without geometry doesn't. +fn beyond(n: &Node) -> bool { + n.geometric_bounds().is_some_and(|b| !rect_in_range(b)) +} + +/// Whether `n` is a layer or group, and if so whether it clips: the containers whose objects are +/// looked at one by one. Anything else is one object, measured whole. +fn clipping(n: &Node) -> Option { + match n.kind { + NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => Some(clip), + _ => None, + } +} + +/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by +/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its +/// clipping path. +fn reaches_beyond(n: &Node) -> bool { + match clipping(n) { + None => beyond(n), + Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))), + } +} + +/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips +/// go together, so no other object becomes the clipping path. +fn clips_beyond(n: &Node) -> bool { + clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c)) +} + +/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go +/// with them. +fn layers_in(n: &Node, layers: &mut Vec) { + if matches!(n.kind, NodeKind::Layer { .. }) { + layers.push(n.id); + } + if clipping(n).is_some() { + for c in n.children().into_iter().flatten() { + layers_in(c, layers); + } + } +} + +/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its +/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything +/// else goes whole. Only containers holding such an object are copied. Recursion follows the +/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`. +fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec) { + if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { + return; + } + let Some(children) = Arc::make_mut(node).children_mut() else { return }; + children.retain_mut(|c| { + if !reaches_beyond(c) { + return true; + } + if clipping(c).is_some() && !clips_beyond(c) { + drop_children_beyond(c, dropped, layers); + return true; + } + layers_in(c, layers); + *dropped += 1; + false + }); +} + +impl Document { + /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects + /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied + /// and stops clipping), artboards with their ruler origins and artboard guides (their ids + /// are retired, as deleting them does), guides with the layer guides of the layers removed, + /// and put a ruler origin beyond it back at the origin. Saved selections, assets and variable + /// bindings then forget the objects removed. Run after [`Document::limit_depth`]. A document + /// whose artboards were all removed has none left. + pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas { + let mut gone = BeyondCanvas::default(); + let mut layers = vec![]; + for layer in &mut self.layers { + if clips_beyond(layer) { + let layer = Arc::make_mut(layer); + if let NodeKind::Layer { children, clip, .. } = &mut layer.kind { + for c in children.iter() { + layers_in(c, &mut layers); + } + gone.objects += children.len(); + children.clear(); + *clip = false; + } + } else { + drop_children_beyond(layer, &mut gone.objects, &mut layers); + } + } + if gone.objects > 0 { + self.tidy_saved_selections(); + self.prune_assets(); + self.prune_variable_bindings(); + } + let removed: Vec = self.artboards.iter().filter(|a| !rect_in_range(a.rect)).map(|a| a.id).collect(); + self.artboards.retain(|a| rect_in_range(a.rect)); + for id in &removed { + self.artboard_ruler_origins.remove(id); + self.retire_artboard_id(*id); + } + gone.artboards = removed.len(); + let guides = self.guides.len(); + self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + gone.guides = guides - self.guides.len(); + if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { + self.ruler_origin = Point::ZERO; + gone.ruler_origin = true; + } + gone + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, Artboard, Guide}; + use vectorcraft_geom::shapes; + + fn rect(id: u64, y: f64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art())) + } + + fn doc(children: Vec>) -> Document { + let mut d = Document::new(100.0, 100.0); + if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + ch.extend(children); + } + d + } + + fn ids(d: &Document) -> Vec { + let mut out = vec![]; + fn walk(n: &Node, out: &mut Vec) { + out.push(n.id.0); + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + for l in &d.layers { + walk(l, &mut out); + } + out + } + + #[test] + fn a_document_within_the_canvas_is_left_alone() { + let mut d = doc(vec![rect(10, 0.0), rect(11, MAX_COORD - 10.0), rect(12, -MAX_COORD)]); + let before = d.clone(); + assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); + assert_eq!(d, before); + assert_eq!(BeyondCanvas::default().note(), None); + } + + #[test] + fn objects_beyond_the_canvas_are_removed_and_groups_keep_the_rest() { + let group = Arc::new(Node::group(NodeId(20), vec![rect(21, 0.0), rect(22, 1e17)])); + let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, f64::NAN), rect(32, 0.0)], clip: true })); + let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), group, clip, rect(12, -MAX_COORD - 1.0)]); + let layer = d.layers[0].id.0; + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 4, ..Default::default() }); + assert_eq!(ids(&d), [layer, 10, 20, 21], "the group keeps its object within the canvas; the clip group goes with its clipping path"); + assert!(d.layers.iter().all(|l| !beyond(l))); + assert_eq!(gone.note().unwrap(), format!("4 objects beyond the canvas (more than {MAX_COORD} pt from the origin) were removed")); + } + + #[test] + fn artboards_guides_and_the_ruler_origin_beyond_the_canvas_are_removed_or_reset() { + let mut d = doc(vec![rect(10, 0.0)]); + let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; + d.artboards.push(far); + let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; + d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.ruler_origin = Point::new(1e17, 0.0); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); + assert_eq!(d.artboards.len(), 1); + assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.ruler_origin, Point::ZERO); + assert_eq!( + gone.note().unwrap(), + format!( + "1 artboard, 3 guides beyond the canvas (more than {MAX_COORD} pt from the origin) were removed; the ruler origin, beyond it too, was reset" + ) + ); + } + + /// A sublayer like the document's first layer, holding `children`. + fn sublayer(d: &Document, id: u64, children: Vec>, clip: bool) -> Arc { + let mut l = (*d.layers[0]).clone(); + l.id = NodeId(id); + if let NodeKind::Layer { children: c, clip: k, .. } = &mut l.kind { + *c = children; + *k = clip; + } + Arc::new(l) + } + + #[test] + fn objects_hidden_or_clipped_inside_a_container_are_looked_at_one_by_one() { + // A layer's bounds leave out its hidden objects, and a clip group's are its clipping path's. + let mut hidden = (*rect(41, 1e17)).clone(); + hidden.visible = false; + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![Arc::new(hidden), rect(42, 0.0)], false); + let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, 0.0), rect(32, 1e17), rect(33, 5.0)], clip: true })); + let mut d = doc(vec![sub, clip]); + let layer = d.layers[0].id.0; + assert_eq!(d.drop_beyond_canvas().objects, 2); + assert_eq!(ids(&d), [layer, 40, 42, 30, 31, 33], "the far hidden object and the far clipped one go; the clipping path stays first"); + } + + #[test] + fn a_top_level_layer_clipping_by_a_path_beyond_the_canvas_is_emptied() { + let mut d = doc(vec![rect(10, 1e17), rect(11, 0.0), rect(12, 5.0)]); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *clip = true; + } + assert_eq!(d.drop_beyond_canvas().objects, 3, "the clipping path and what it clipped go together"); + assert!( + matches!(&d.layers[0].kind, NodeKind::Layer { children, clip: false, .. } if children.is_empty()), + "no other object becomes the clipping path" + ); + // A clipping sublayer goes whole, as a clip group does. + let mut d = doc(vec![]); + let sub = sublayer(&d, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); + let mut d2 = doc(vec![sub, rect(10, 0.0)]); + let layer = d2.layers[0].id.0; + assert_eq!(d2.drop_beyond_canvas().objects, 1); + assert_eq!(ids(&d2), [layer, 10]); + assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); + } + + #[test] + fn a_removed_artboards_ruler_origin_goes_and_its_id_is_not_given_again() { + let mut d = doc(vec![]); + let kept = d.artboards[0].id; + d.artboards.push(Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }); + d.artboard_ruler_origins.insert(kept, vectorcraft_geom::Vec2::new(1.0, 2.0)); + d.artboard_ruler_origins.insert(9, vectorcraft_geom::Vec2::new(3.0, 4.0)); + assert_eq!(d.drop_beyond_canvas().artboards, 1); + assert_eq!(d.artboard_ruler_origins.keys().copied().collect::>(), [kept]); + assert!(d.next_artboard_id() > 9, "a new artboard doesn't take the removed one's id"); + } + + #[test] + fn what_names_a_removed_object_forgets_it() { + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); + let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); + d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); + d.assets = vec![ + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + ]; + d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); + d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); + let gone = d.drop_beyond_canvas(); + assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); + assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); + assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); + assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a17b430be..478e54ca8 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -3,7 +3,10 @@ use super::graphic_style::default_graphic_styles; use super::guides::{GridPrefs, RulerMode}; use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID}; use super::units::{ColorMode, Unit}; -use super::*; +use super::{ + Arc, BTreeMap, ColorProfiles, DocMetadata, DocSetup, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, PatternDef, Places, Point, + RasterEffectsSettings, Rect, Vec2, +}; impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index 37fd869c3..e866d15e8 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -1,7 +1,10 @@ use super::graphic_style::GraphicStyle; use super::limits::MAX_NEXT_ID; use super::records::{ImageBlob, SavedSelection, SavedView}; -use super::*; +use super::{ + Arc, Document, EnvelopeKind, ImageObject, LiveShape, Node, NodeKind, Point, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, + SymbolExportType, SymbolNineSlice, SymbolRegistration, placed_document, +}; impl Document { /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 96af5c75b..43e1c4c92 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,6 @@ use super::document_ext::free_id; use super::guides::Guide; -use super::*; +use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol diff --git a/crates/doc/src/graphic_style.rs b/crates/doc/src/graphic_style.rs index 95c61b6eb..789684736 100644 --- a/crates/doc/src/graphic_style.rs +++ b/crates/doc/src/graphic_style.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Appearance, Deserialize, Knockout, Node, Rect, Serialize, skip}; /// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 0c10efff2..08f828391 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, NodeId, Serialize, Vec2}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 18873d6d9..91ede3df9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -117,6 +117,7 @@ use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; mod artboard; +mod canvas_range; mod document; mod document_ext; mod document_guides; @@ -127,6 +128,7 @@ mod records; mod units; pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle}; diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index e030f3d71..027148ade 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -1,4 +1,4 @@ -use super::*; +use super::NodeId; /// Coordinates beyond this many points exceed the supported document range. pub const MAX_COORD: f64 = 4.0e6; diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 79c129030..75df5eee1 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, PathData, Serialize}; // ===================================================================================== // Data diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 13d30cef0..6604c86d1 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -1,6 +1,6 @@ use super::expand::{Hooks, Outliner, Warper, map_node}; use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; -use super::*; +use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 09eada113..c67dbee3e 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -1,7 +1,10 @@ -use super::envelope::{EnvelopeOptions, expand_envelope}; +use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope}; use super::mesh::GradientMesh; use super::warp::map_nonlinear; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2, + blend_expand, +}; /// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; @@ -154,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { /// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into /// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its -/// edges (at most half a point) so neighbours overlap and no seams show. +/// that takes the triangle where the warp takes its corners (with Preserve Shape Using +/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). +/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no +/// seams show. fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); // Two triangles a cell, three objects each; an image an inner envelope already cut into @@ -186,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { if len > 1e-12 { *p + v * (grow / len) } else { *p } }) .collect(); - let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); + let shape = PathData::single(SubPath::polyline(&tri, true)); + let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); + image.extras_mut().pixelated = !w.options.anti_alias; + if w.options.preserve_shape == PreserveShape::Transparency { + // Preserve Shape Using Transparency: the triangle is the piece's alpha (a + // white opacity mask) instead of its clipping path. + let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))))); + continue; + } + let mut clip = Node::path(n.id, shape, Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); } } diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 2cbe7602c..783e32556 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,6 +1,9 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; -use super::*; +use super::{ + Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, + one, +}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MeshPoint { diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index cacf26ec5..6d7392ac5 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Anchor, BezPath, CubicBez, PI, ParamCurve, PathData, Point, SubPath}; // ===================================================================================== // Warp maps (shared with vectorcraft-effects' Warp effects) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2be25e0b1..3dd07351e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -28,10 +28,11 @@ pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttribut pub use clip::{ClipPaint, Uniter}; pub use extras::{Extras, ExtrasMut, NodeExtras}; pub use ids::{LAYER_COLORS, LayerColor, NodeId}; -pub use kind::{ImageObject, NodeKind}; +pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; use wire::NodeWire; +pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { true diff --git a/crates/doc/src/node/attributes.rs b/crates/doc/src/node/attributes.rs index 70653d778..42e41654f 100644 --- a/crates/doc/src/node/attributes.rs +++ b/crates/doc/src/node/attributes.rs @@ -1,6 +1,6 @@ use super::kind::NodeKind; use super::live_shape::LiveShape; -use super::*; +use super::{Deserialize, Node, Serialize}; /// Window → Attributes: an object's settings that don't change how it prints. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 959b20a5b..208bdda7f 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -1,5 +1,5 @@ use super::kind::NodeKind; -use super::*; +use super::{BezPath, FillRule, Node, Outliner, Rect, shapes}; /// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans /// live above this crate (`vectorcraft-pathops`), so callers supply it. diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index ee1165303..2d8ad8c9c 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -2,7 +2,7 @@ use super::attributes::ObjectAttributes; use super::kind::NodeKind; use super::live_shape::LiveShape; use super::opacity::OpacityMask; -use super::*; +use super::{Node, PathData}; /// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without /// them stay small ([`Node::extras`]). @@ -23,6 +23,9 @@ pub struct NodeExtras { /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale /// the placed gradients in it with the outlines. pub type_outlines: bool, + /// An image drawn without smoothing: each pixel of the picture takes the color of the image + /// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off). + pub pixelated: bool, /// Editable Shaper composition; the original art is retained in its source child. pub shaper: Option>, /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps @@ -56,6 +59,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { graph: None, series_index: None, type_outlines: false, + pixelated: false, shaper: None, graphic_style: None, attrs: None, diff --git a/crates/doc/src/node/ids.rs b/crates/doc/src/node/ids.rs index ea4b1b484..9e09a47a9 100644 --- a/crates/doc/src/node/ids.rs +++ b/crates/doc/src/node/ids.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Stable per-document object id. Never reused. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 5bebc4379..22470cbf3 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -1,6 +1,6 @@ use super::ids::LayerColor; use super::live_shape::LiveShape; -use super::*; +use super::{Affine, Arc, BlendSpec, Deserialize, EnvelopeKind, FillRule, GradientMesh, Node, PathData, RepeatSpec, Serialize, TextObject, yes}; /// Embedded or linked raster image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -129,6 +129,8 @@ pub enum NodeKind { /// anywhere among its fields. Files have it first (it is written first): then the kind is read /// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the /// fields before `type` are buffered, and only those. +pub use kind_de::{MAX_BUFFERED_VALUES, with_buffered_value_budget}; + mod kind_de { use std::fmt; use std::sync::Arc; @@ -237,12 +239,150 @@ mod kind_de { let tag: String = map.next_value()?; return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); } - before.push((key, map.next_value::()?)); + before.push((key, map.next_value::()?.0)); } Err(de::Error::missing_field("type")) } } + /// Most JSON values (each number, string, array, object…) the fields read before an object's + /// `type` may hold together while [`with_buffered_value_budget`] runs: 32M (about 1 GB as + /// `serde_json::Value`s, some 32 bytes each), 4M in the web build. VectorCraft writes `type` + /// first, so its files buffer nothing; a reordered file buffers what comes before it, and + /// a crafted one could fill memory with values two bytes each in the file before any object in + /// them is counted (#3587). + pub const MAX_BUFFERED_VALUES: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 }; + + std::thread_local! { + /// Values the fields buffered on this thread may still hold, and the budget they come from. + static VALUES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Buffered fields being read back: what they hold was counted when it was buffered. + static REPLAYING: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// Run `f` (which reads objects with serde) letting the fields it buffers before an object's + /// `type` hold at most `max` values together; buffering more fails. + pub fn with_buffered_value_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option<(usize, usize)>); + impl Drop for Restore { + fn drop(&mut self) { + VALUES_LEFT.set(self.0); + } + } + let _restore = Restore(VALUES_LEFT.replace(Some((max, max)))); + f() + } + + /// While buffered fields are read back ([`REPLAYING`]). + struct Replaying; + + impl Replaying { + fn enter() -> Self { + REPLAYING.set(REPLAYING.get().saturating_add(1)); + Replaying + } + } + + impl Drop for Replaying { + fn drop(&mut self) { + REPLAYING.set(REPLAYING.get().saturating_sub(1)); + } + } + + /// Count one buffered value against [`with_buffered_value_budget`]. + fn count() -> Result<(), E> { + if REPLAYING.get() > 0 { + return Ok(()); + } + if let Some((left, max)) = VALUES_LEFT.get() { + let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("an object has more than {max} values before its type")))?; + VALUES_LEFT.set(Some((left, max))); + } + Ok(()) + } + + /// A field read before `type`, as a `serde_json::Value` counted value by value as it is read, + /// so the budget stops a large one before it is all in memory. + struct Counted(serde_json::Value); + + impl<'de> Deserialize<'de> for Counted { + fn deserialize>(d: D) -> Result { + d.deserialize_any(CountedVisitor) + } + } + + struct CountedVisitor; + + impl<'de> Visitor<'de> for CountedVisitor { + type Value = Counted; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, v: bool) -> Result { + count()?; + Ok(Counted(serde_json::Value::Bool(v))) + } + + fn visit_i64(self, v: i64) -> Result { + count()?; + Ok(Counted(v.into())) + } + + fn visit_u64(self, v: u64) -> Result { + count()?; + Ok(Counted(v.into())) + } + + fn visit_f64(self, v: f64) -> Result { + count()?; + Ok(Counted(serde_json::Number::from_f64(v).map_or(serde_json::Value::Null, serde_json::Value::Number))) + } + + fn visit_str(self, v: &str) -> Result { + count()?; + Ok(Counted(serde_json::Value::String(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + count()?; + Ok(Counted(serde_json::Value::String(v))) + } + + fn visit_none(self) -> Result { + self.visit_unit() + } + + fn visit_unit(self) -> Result { + count()?; + Ok(Counted(serde_json::Value::Null)) + } + + fn visit_some>(self, d: D) -> Result { + Counted::deserialize(d) + } + + fn visit_seq>(self, mut seq: S) -> Result { + count()?; + let mut out = Vec::new(); + while let Some(Counted(v)) = seq.next_element()? { + out.push(v); + } + Ok(Counted(serde_json::Value::Array(out))) + } + + fn visit_map>(self, mut map: M) -> Result { + count()?; + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + let Counted(v) = map.next_value()?; + out.insert(key, v); + } + Ok(Counted(serde_json::Value::Object(out))) + } + } + /// The fields read before `type` (buffered), then the rest of the object. struct Buffered { before: std::vec::IntoIter<(String, serde_json::Value)>, @@ -265,7 +405,11 @@ mod kind_de { fn next_value_seed>(&mut self, seed: V) -> Result { match self.value.take() { - Some(value) => seed.deserialize(value).map_err(de::Error::custom), + Some(value) => { + // Counted when it was read: the objects it holds buffer it again as they read it. + let _replaying = Replaying::enter(); + seed.deserialize(value).map_err(de::Error::custom) + } None => self.map.next_value_seed(seed), } } diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs index 65dad904e..93f2022e0 100644 --- a/crates/doc/src/node/live_shape.rs +++ b/crates/doc/src/node/live_shape.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, CornerKind, Deserialize, PathData, Point, Rect, Serialize, cut_corners, shapes}; /// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these /// while the shape stays live; editing anchors directly converts it to a plain path. diff --git a/crates/doc/src/node/opacity.rs b/crates/doc/src/node/opacity.rs index 0996b8c65..903c84b14 100644 --- a/crates/doc/src/node/opacity.rs +++ b/crates/doc/src/node/opacity.rs @@ -1,5 +1,5 @@ use super::kind::NodeKind; -use super::*; +use super::{Arc, Color, Deserialize, Node, Paint, Serialize, yes}; /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 6cc0cb3be..e02958a8d 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -3,7 +3,7 @@ use super::ids::{LayerColor, NodeId}; use super::kind::NodeKind; use super::live_shape::{Scaling, moves_only}; use super::opacity::Knockout; -use super::*; +use super::{Affine, Appearance, Arc, BlendMode, EnvelopeKind, FillRule, Node, PathData, Point, Rect, RepeatSpec, SYMBOL_SQUARE, Shape, SymbolBoxes}; impl Node { pub fn new(id: NodeId, kind: NodeKind) -> Self { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 3e7a399f2..0565c9e07 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -433,3 +433,37 @@ fn bad_kinds_are_errors() { assert!(serde_json::from_str::(bad).is_err(), "{bad}"); } } + +/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past +/// it with a message naming the budget, and is gone once its reading is done. +#[test] +fn objects_are_read_within_the_node_budget() { + let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]); + let json = serde_json::to_string(&group).unwrap(); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + assert_eq!(read().unwrap(), group, "no budget outside a read"); + // A budget left by a read that failed doesn't carry over to the next one. + assert!(with_node_budget(1, read).is_err()); + assert!(with_node_budget(3, read).is_ok()); +} + +/// #3587: fields read before an object's `type` are buffered value by value within +/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers +/// its own fields again without counting them twice. VectorCraft writes `type` first, which +/// buffers nothing. +#[test] +fn fields_before_the_type_are_buffered_within_the_value_budget() { + let reordered = r#"{"id":1,"kind":{"children":[{"id":2,"kind":{"children":[],"type":"group"}}],"type":"group"}}"#; + let read = |json: &str| serde_json::from_str::(json); + // The children's array, the inner object, its id, its kind, the kind's empty children and + // its type: six values. + assert!(with_buffered_value_budget(6, || read(reordered)).is_ok()); + let why = with_buffered_value_budget(5, || read(reordered)).unwrap_err().to_string(); + assert!(why.contains("more than 5 values before its type"), "{why}"); + let written = serde_json::to_string(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![]))])).unwrap(); + assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}"); + assert!(read(reordered).is_ok(), "no budget outside a read"); +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 43fcfea92..84d0b220e 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -3,7 +3,7 @@ use super::extras::{Extras, NO_EXTRAS, NodeExtras}; use super::ids::NodeId; use super::kind::NodeKind; use super::opacity::{Knockout, OpacityMask}; -use super::*; +use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes}; /// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from /// the node and its extras (nothing is copied). The node reads back through `NodeWire`. @@ -30,6 +30,7 @@ impl Serialize for Node { e.graph.is_none(), e.series_index.is_none(), is_default(&e.type_outlines), + is_default(&e.pixelated), e.shaper.is_none(), false, e.graphic_style.is_none(), @@ -69,6 +70,7 @@ impl Serialize for Node { field!("graph", &e.graph); field!("series_index", &e.series_index); field!("type_outlines", &e.type_outlines); + field!("pixelated", &e.pixelated); field!("shaper", &e.shaper); field!("kind", &self.kind); field!("graphic_style", &e.graphic_style); @@ -84,11 +86,48 @@ impl Serialize for Node { } } +/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest +/// objects), 512K in the web build. Paths have their own anchor budget +/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves. +pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 }; + +std::thread_local! { + /// Objects the document being read on this thread may still have, and the budget they come + /// from ([`with_node_budget`]). + static NODES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading +/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes +/// once read, about 270 bytes each against some 50 in the file (#3587). +pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option<(usize, usize)>); + impl Drop for Restore { + fn drop(&mut self) { + NODES_LEFT.set(self.0); + } + } + let _restore = Restore(NODES_LEFT.replace(Some((max, max)))); + f() +} + +/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so +/// each counts once, and the id usually comes first, before the node's children are read. +fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let id = NodeId::deserialize(d)?; + if let Some((left, max)) = NODES_LEFT.get() { + let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + NODES_LEFT.set(Some((left, max))); + } + Ok(id) +} + /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node /// (and its extras, allocated only when one is set) without a copy of what they own. #[derive(Deserialize)] #[serde(rename = "Node")] pub(super) struct NodeWire { + #[serde(deserialize_with = "counted_id")] id: NodeId, #[serde(default)] name: Option, @@ -121,6 +160,8 @@ pub(super) struct NodeWire { #[serde(default)] type_outlines: bool, #[serde(default)] + pixelated: bool, + #[serde(default)] shaper: Option>, kind: NodeKind, #[serde(default)] @@ -152,6 +193,7 @@ impl From for Node { graph: w.graph, series_index: w.series_index, type_outlines: w.type_outlines, + pixelated: w.pixelated, shaper: w.shaper, graphic_style: w.graphic_style, attrs: w.attrs, diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index e9af0e682..b13a1e955 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Node, NodeId, Point, Serialize}; /// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in /// place, so a selection change that sets it doesn't replace the document. diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index b5a1ffe32..875fcf3a0 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -145,6 +145,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[ ("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }), ("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }), diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 8dbed28ae..692fa868c 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -108,9 +108,10 @@ impl Document { Some(list.remove(i)) } - /// Visit every colour that can link to a swatch — solid paints and gradient stops of fills, - /// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern - /// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops). + /// Visit every color that can link to a swatch (solid paints and gradient stops of fills, + /// strokes and text runs) in the art (opacity masks included), symbol definitions, graph + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles + /// and the swatches themselves (tint swatches, gradient swatches' stops). /// `f(colour, link, tint)` may change them and returns true when it did /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. @@ -119,7 +120,10 @@ impl Document { for s in &mut self.symbols { n += map_tree(&mut s.art, f); } - for p in &mut self.patterns { + for g in &mut self.graph_designs { + n += map_tree(&mut g.art, f); + } + for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { n += map_trees(&mut p.art, f); } for gs in &mut self.graphic_styles { @@ -307,6 +311,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { } } +/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a +/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only +/// the nodes holding a changed paint are copied. Returns the number of changed paints. +pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { + match map_node(n, f) { + Some((new, count)) => { + *n = new; + count + } + None => 0, + } +} + fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { match map_node(n, f) { Some((new, count)) => { @@ -400,6 +417,25 @@ mod tests { assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0])); } + #[test] + fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() { + let (mut d, a, _) = doc(); + let art = d.node_arc(a).cloned().unwrap(); + d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() }); + d.pattern_edit = + Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) }); + let mut loose = d.node(a).unwrap().clone(); + let blue = Color::rgb(0.0, 0.0, 1.0); + let mut to_blue = |c: &mut Color, s: &mut Option, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; + assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile"); + assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand")); + let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0]; + assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1); + assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change"); + } + #[test] fn mapping_within_ids_visits_each_paint_once() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 8fb97a700..8f095566b 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -71,6 +71,8 @@ pub struct SymbolBoxes<'a> { /// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`] /// ([`Self::unplaced_reach`]). unplaced: RefCell>, + /// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]). + runs: Cell, } impl Document { @@ -87,6 +89,7 @@ impl Document { holders: Cell::new(0), farthest: RefCell::default(), unplaced: RefCell::default(), + runs: Cell::new(0), } } } @@ -146,9 +149,16 @@ impl<'a> SymbolBoxes<'a> { pub fn measure_art(&self, name: &str, measure: impl FnOnce(&Arc) -> R) -> Option { self.depth(name)?; let sym = self.doc.symbols.iter().find(|s| s.name == name)?; + self.runs.set(self.runs.get() + 1); Some(measure(&sym.art)) } + /// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the + /// measures behind the cached art boxes and placed visual boxes included. + pub fn art_measured(&self) -> usize { + self.runs.get() + } + /// The geometric box of the art of the symbol named `name` in symbol space, with the instances /// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of /// the symbol are then measured by [`SYMBOL_SQUARE`]. @@ -460,6 +470,26 @@ mod tests { assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0))); } + #[test] + fn each_symbols_art_box_is_measured_once() { + // `Pair` holds two instances of `Dot`; three instances of `Pair` are measured twice each. + let mut d = Document::new(200.0, 200.0); + let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0); + symbol(&mut d, "Dot", vec![dot]); + let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0))); + let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0))); + symbol(&mut d, "Pair", vec![right, below]); + let pairs: Vec = (0..3).map(|i| instance(&mut d, "Pair", Affine::translate((f64::from(i) * 10.0, 0.0)))).collect(); + let s = d.symbol_boxes().quick(); + assert_eq!(s.art_measured(), 0); + for _ in 0..2 { + for p in &pairs { + p.geometric_bounds_with(&s); + } + } + assert_eq!(s.art_measured(), 2, "the art of Pair and of Dot"); + } + #[test] fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() { // A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 5897d957a..b30afd412 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -1,5 +1,5 @@ use super::para_style::FirstBaseline; -use super::*; +use super::{Deserialize, PathData, Serialize}; /// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index a19b5f71c..0e3aea70e 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -1,5 +1,5 @@ use super::para_style::ParaDirection; -use super::*; +use super::{Appearance, AppearanceItem, Color, Dash, Deserialize, FillLayer, LineCap, LineJoin, Paint, Serialize, StrokeAlign, StrokeLayer}; /// Character attributes (the Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 58caa1602..de77da298 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -3,7 +3,7 @@ use super::char_style::CharStyle; use super::para_style::ParaStyle; use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; use super::tabs_lists::ParaList; -use super::*; +use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; impl TextObject { /// None for collapsed or unrepresentable transforms, whose type dimensions cannot diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index 6a01a1815..a257e2e0a 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -1,6 +1,6 @@ use super::char_style::CharStyle; use super::tabs_lists::{ParaList, TabStop}; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index 4e3de9338..a540ba210 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -1,5 +1,5 @@ use super::char_style::CharStyle; -use super::*; +use super::{Affine, Deserialize, PathData, Point, Rect, Serialize, Vec2, yes}; /// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or /// [`ParaStyle`] fields, by their serialized names). Text using it records the name. diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 763a82c69..47106d788 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -1,5 +1,5 @@ use super::char_style::{CharPosition, CharStyle}; -use super::*; +use super::{Deserialize, Serialize}; /// Tab stop alignment (Tabs panel). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 1b09c4e7b..2c1fc8653 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Measurement units (display only; the model is always in points). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 14fd90c13..e000b930f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -264,7 +264,7 @@ pub fn effect_catalog() -> Vec { "stylize.dropShadow", "Drop Shadow…", STYLIZE, - "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": use d = darkness / 100 on the source colors before document color conversion and blur; RGB channels become max(0, channel - d), CMYK keeps C/M/Y and sets K to min(1, K + d), and Gray sets its black component to min(1, K + d). The near-100% endpoint is gray black. Shadow opacity remains separate)", json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), ), r( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index b7430e0c7..5b0c14c62 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -4,7 +4,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; -use crate::util::*; +use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; fn diag(b: Rect) -> f64 { b.width().hypot(b.height()).max(1e-6) diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 7d25d1955..a4cb2e1d8 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -1,5 +1,8 @@ use super::params::{is_geometry, merged_params}; -use super::*; +use super::{ + Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize, + util, warp, +}; /// Context the geometry effects may need from the object's appearance. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -167,7 +170,7 @@ fn builds_within(id: &str) -> bool { /// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { - use util::*; + use util::{join, num}; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index dc54e8b51..d64ac7b78 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -1,5 +1,5 @@ use super::catalog::{catalog_index, plugin_info}; -use super::*; +use super::{CROP_MARKS, Effect, Map, Node, PIXEL_EFFECTS, RASTERIZE, Value, is_adjustment, is_pathfinder, is_three_d, json, util}; /// Dialog defaults for `id` (`None` for unknown effects). pub fn default_params(id: &str) -> Option { diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index fc3497ae6..ef4d9d7ab 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1755,6 +1755,10 @@ fn brush_strokes_params_take_defaults_and_stay_in_range() { fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})), PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 } ); + brush_strokes_outsets(); +} + +fn brush_strokes_outsets() { // Outsets: the strokes and the shifts reach out of the object as far as into it; Accented // Edges and Sumi-e keep the object's shape. let b = Rect::new(0.0, 0.0, 100.0, 50.0); @@ -2561,6 +2565,10 @@ fn artistic_params_take_defaults_and_stay_in_range() { fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})), PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 } ); + artistic_outsets_and_docs(); +} + +fn artistic_outsets_and_docs() { for id in [ "artistic.coloredPencil", "artistic.cutout", diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 79ad9c25e..f8590bbb2 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -9,7 +9,7 @@ use vectorcraft_geom::{Point, Rect}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; -use crate::util::*; +use crate::util::{flag, num, text}; /// Effect › Rasterize's id. pub const RASTERIZE: &str = "rasterize"; diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 98bf2dd67..f7dd503d2 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -5,7 +5,7 @@ use vectorcraft_geom::kurbo; use vectorcraft_geom::shapes::{self, KAPPA}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners}; -use crate::util::*; +use crate::util::{catmull_rom, flag, noise, num, seed}; /// Convert to Shape (rectangle / rounded rectangle / ellipse) around the bounds. pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData { diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index f3b7efea9..fb0279ede 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; -use crate::util::*; +use crate::util::{map_nonlinear_within, num, text}; pub use vectorcraft_doc::live::{WarpStyle, warp_point}; diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 411f2004a..6b658da0d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -179,6 +179,21 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } +/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items, +/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask +/// editing isolate their art too, and do not count as isolation mode here while their own layer +/// is the isolated one. An object isolated inside them counts. +pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l); + if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) } +} +/// Objects are selected ([`has_selection`]) and the document is not in isolation mode +/// ([`outside_isolation`]). +pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> { + outside_isolation(s)?; + has_selection(s) +} pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 3e1173d8b..9eee69d27 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -28,8 +28,11 @@ use vectorcraft_geom::Rect; use super::edit::{outermost, selected_roots}; use super::opacitymask::percent; use super::paint::paint_from; -use super::*; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; +use super::{ + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, + ok, paint_targets, str_param, switch_or, targets, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index c560a35c8..975b8e93f 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs index 7fdc5f1bf..0265b4ccd 100644 --- a/crates/engine/src/cmd/attributes.rs +++ b/crates/engine/src/cmd/attributes.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::FillRule; use super::overprint::{AttributesInfo, apply_overprint, edit_counted, overprint_jobs}; use super::path::paths_in; -use super::*; +use super::{CommandSpec, NodeId, Result, Session, bad, cmd, has_doc, str_param, targets}; /// How many URLs the Attributes panel's recent list keeps. const RECENT_URLS: usize = 10; diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 1c7de9978..9bb04dac9 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -22,7 +22,7 @@ use super::clipboard::resources::SwatchChoices; use super::patterncmds::add_pattern; use super::swatch::str_list; use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; /// The extension of saved brush libraries: native documents. pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b677b3003..8ef171d32 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -18,7 +18,10 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, ids_param, number_or, ok, + str_param, switch_or, +}; use crate::EngineError; mod brushes; @@ -131,7 +134,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 18f744b1d..66dd546e9 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value, + anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, + str_param, +}; // ---------- brushes ---------- diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 63eeed6b5..8e8ba163e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, + SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, + name_param, number_or, ok, remove_each, roots, selection_art, separate, set_unknown, str_param, switch_or, +}; // ---------- symbols ---------- diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 8d72ce335..155e7d164 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -10,6 +10,7 @@ //! and the traced filled paths above it, mapped with the image's transform. Expand drops the //! image (a plain group of paths remains); Release puts the image back in place of the group. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -27,7 +28,10 @@ use super::draw2::replace_with_pieces; use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, ids_param, + number_or, point_param, str_param, switch_or, +}; mod live_paint; mod shape_builder; @@ -48,7 +52,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), @@ -298,6 +302,89 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) n } +/// Put each list of `outs` in place of the leaf of `leaves` at the same index (none: the leaf stays +/// as it is), then remove the groups under `roots` that this leaves empty. A Live Paint or Shaper +/// group (the outermost one under the root) is replaced as a whole, by the lists of its leaves, +/// with a copy of each leaf that stays and of each hidden or locked object in it. Other objects +/// under the roots, such as type, hidden or locked objects and clipping paths, stay where they are. Returns what to select: each root +/// still there, or what went in its place. +fn replace_leaves(d: &mut Document, roots: &[NodeId], leaves: &[Node], outs: Vec>>) -> Result> { + let listed: HashSet = roots.iter().copied().collect(); + let paths = d.index_paths(leaves.iter().map(|l| l.id)); + // Each leaf's unit (the leaf, or the group replaced as a whole) and the groups from the unit's + // parent up to its root, innermost first. + let mut places: Vec<(NodeId, Vec)> = vec![]; + for leaf in leaves { + let anc = paths.get(&leaf.id).and_then(|p| d.ancestry_at(p)).unwrap_or_default(); + let below = anc.iter().position(|a| listed.contains(a)).and_then(|r| anc.get(r..)).unwrap_or_default(); + let whole = below.iter().position(|a| d.node(*a).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some())); + let at = whole.unwrap_or(below.len().saturating_sub(1)); + let unit = below.get(at).copied().unwrap_or(leaf.id); + places.push((unit, below.get(..at).unwrap_or_default().iter().rev().copied().collect())); + } + // (unit, groups above it, what goes in place of each of its leaves, whether it changes); a + // unit's leaves are next to each other, in paint order. + type Unit = (NodeId, Vec, Vec<(NodeId, Vec)>, bool); + let mut units: Vec = vec![]; + for ((leaf, out), (unit, above)) in leaves.iter().zip(outs).zip(places) { + let changed = out.is_some(); + let list = match out { + Some(list) => list, + None if unit != leaf.id => vec![d.reid(leaf)], + None => vec![], + }; + match units.last_mut() { + Some(u) if u.0 == unit => { + u.2.push((leaf.id, list)); + u.3 |= changed; + } + _ => units.push((unit, above, vec![(leaf.id, list)], changed)), + } + } + units.retain(|u| u.3); + let ids: Vec = units.iter().map(|u| u.0).collect(); + let mut replaced = Beside::replacing(d, &ids); + let mut put: HashMap> = HashMap::new(); + let mut emptied = vec![]; + for (unit, above, lists, _) in units { + // A group replaced as a whole keeps a copy of each hidden or locked object in it, where it was. + let members: Vec = d.node(unit).map(|n| b::builder_members(n).into_iter().cloned().collect()).unwrap_or_default(); + let mut at: HashMap = HashMap::new(); + for (k, l) in lists.iter().enumerate() { + at.entry(l.0).or_insert(k); + } + let mut lists: Vec>> = lists.into_iter().map(|l| Some(l.1)).collect(); + let mut list = vec![]; + for m in &members { + if let Some(l) = at.remove(&m.id).and_then(|k| lists.get_mut(k)).and_then(Option::take) { + list.extend(l); + } else if (m.locked || !m.visible) && m.id != unit { + list.push(d.reid(m)); + } + } + list.extend(lists.into_iter().flatten().flatten()); + if list.is_empty() { + emptied.push(above); + } + put.insert(unit, list.iter().map(|n| n.id).collect()); + replaced.put(d, unit, list)?; + } + replaced.finish(d)?; + // A group goes only when nothing is left in it. + for above in emptied { + for g in above { + match d.node(g) { + None => continue, + Some(n) if !n.is_layer() && n.children().is_some_and(|c| c.is_empty()) => { + d.remove(g)?; + } + Some(_) => break, + } + } + } + Ok(roots.iter().flat_map(|r| put.get(r).cloned().unwrap_or_else(|| d.node(*r).map(|_| vec![*r]).unwrap_or_default())).collect()) +} + // ---------- Live Paint ---------- /// The source nodes a root contributes (a Live Paint group contributes its own sources). diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index f9b224ce8..207182d40 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, + is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, +}; /// `options` as stored on a Live Paint group: none for the defaults. fn stored_gap_options(options: GapOptions) -> Option> { diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 07d4949a0..5cbcdea97 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, + f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids, + shape_node, switch_or, +}; pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "shapeBuilder.merge"; @@ -108,22 +112,24 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let covered = if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; let style_src = covered.or(map.shapes.len().checked_sub(1)); - let mut new_nodes: Vec = vec![]; + // What goes in place of each object the map was made from (none: it stays as it is). + let mut outs: Vec>> = vec![]; let mut merged_id = None; for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + let mut out = None; let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); if !pieces.is_empty() { // An open path loses the pieces deleted and those between two merged (or deleted) regions. let keep: Vec = pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); - if keep.len() == pieces.len() { - new_nodes.push(d.reid(leaf)); - } else { + if keep.len() != pieces.len() { + let mut kept = vec![]; for k in keep { if let Some((l, _)) = map.lines.get(k) { - new_nodes.push(remnant(d, leaf, l.path.clone(), None)); + kept.push(remnant(d, leaf, l.path.clone(), None)); } } + out = Some(kept); } } else { // A shape loses the regions deleted or merged, then the edges deleted (each one on @@ -156,23 +162,24 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { cut = true; } } - if !in_union && !cut { - new_nodes.push(d.reid(leaf)); - } else if !rest.is_empty() { + if in_union || cut { // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); + out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] }); } } + // The merged shape goes in place of the object it takes its look from, so it stays in + // that object's group (a clip group too). if !erase && !union.is_empty() && Some(i) == style_src { let mut m = shape_node(d, union.clone(), Some(leaf)); if let Some(f) = &fill { m.appearance.set_fill(f.clone()); } merged_id = Some(m.id); - new_nodes.push(m); + out.get_or_insert_with(|| vec![leaf.clone()]).push(m); } + outs.push(out); } - let new_ids = replace_roots(d, &roots, new_nodes)?; + let new_ids = replace_leaves(d, &roots, &leaves, outs)?; sel.set(new_ids.iter().copied()); Ok((new_ids, merged_id, touched, None)) })?; diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 2eb4bcfcc..c1e66d27c 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, +}; // ---------- Image Trace ---------- diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index a01f095fc..887c5b47a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -25,7 +25,7 @@ pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, P pub(crate) use resources::SwatchChoices; pub use resources::{ArtResources, art_resources}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, point_param, str_param}; use crate::DocState; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 08098705f..b7b046824 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{Affine, Point}; use super::super::fileio; use super::super::place::pt_per_px; -use super::*; +use super::{Clipboard, CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, looks_like_svg, point_param, str_param}; /// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code). pub const TEXT: &str = "text/plain"; diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 6fdc10f40..49b729c8a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -18,7 +18,10 @@ use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, bool_or, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param, + str_param, unique_name, +}; pub fn specs() -> Vec { vec![ @@ -68,7 +71,7 @@ pub fn specs() -> Vec { "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", + "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", has_selection, saturate ), @@ -343,18 +346,21 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result Result { - let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32; + let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); - if sat <= 0.0 { + let to = (sat * (1.0 + i)).clamp(0.0, 1.0); + // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. + if sat <= 0.0 || to == sat { return c; } - keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v)) + keep_model(c, Color::from_hsb(h, to, v)) }) } fn adjust_balance(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colors.adjustBalance"; + check_numbers(p, &["r", "g", "b", "c", "m", "y", "k", "gray", "tint"], C)?; let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some()); let mode = match str_param(p, "mode") { Some(m) => m.to_ascii_lowercase(), @@ -511,6 +517,7 @@ fn harmony(s: &mut Session, p: &Value) -> Result { let ids: Vec = Harmony::ALL.iter().map(|h| h.id()).collect(); bad(C, format!("unknown rule `{rule}` ({})", ids.join("|"))) })?; + check_numbers(p, &["steps", "amount"], C)?; let mut opts = GuideOptions::default(); if let Some(n) = p.get("steps").and_then(Value::as_u64) { opts.steps = n.min(u32::MAX as u64) as u32; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 40144f13e..b1ad12800 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -17,7 +17,9 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; use vectorcraft_render::proof; -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::EngineError; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, color_value, has_doc, str_param, targets}; /// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated /// to `Document::color_profiles` on load). @@ -320,12 +322,16 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result { Ok(json!({ "changed": changed })) } -/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols, -/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many -/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the -/// black plate in either mode); RGB greys are colours like any other and separate through the -/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour -/// definitions) are device independent and fit either mode. Document Color Mode and +/// Put `d` in color `mode` and, with `convert`, convert every color of its art (opacity masks +/// included), symbols, graph designs, pattern tiles (the tile that pattern editing's Cancel puts +/// back included), graphic styles, brushes (their art and a Hue Shift key color) and swatches +/// through the color settings (`intent`, default: theirs) → how many colors of the art (opacity +/// masks included), symbols, graph designs and pattern tiles changed; the colors of graphic +/// styles and brushes convert without being counted. Gray colors stay +/// Gray (they print on the black plate in either mode); RGB grays are colors like any other and +/// separate through the CMYK profile, unless `grays` puts them on the black plate. Lab colors +/// (spot color definitions) are device independent and fit either mode. A tint of a global +/// swatch becomes the converted swatch's color at its tint. Document Color Mode and /// `document.open {colorMode}` both convert this way. pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option, grays: Grays) -> usize { d.color_mode = mode; @@ -342,18 +348,6 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent), _ => c.convert(col, model, intent), }; - let mut changed = 0usize; - let mut count = |col: &Color, _: proof::Link| { - let n = conv(col); - if n != *col { - changed += 1; - } - n - }; - proof::map_document_colors(d, &mut count); - for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) { - proof::map_node_colors(Arc::make_mut(a), &mut count); - } for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -367,6 +361,73 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, } // Tints are their converted swatch's colour at their tint (exact ink percentages). let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); + let changed = std::cell::Cell::new(0usize); + let mut count = |col: &Color, link: proof::Link| { + let n = match link.and_then(|(name, tint)| Some((bases.iter().find(|(n, _)| n == name)?, tint))) { + Some(((_, base), tint)) if tint < 1.0 => base.tinted(tint), + _ => conv(col), + }; + if n != *col { + changed.set(changed.get() + 1); + } + n + }; + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|e| e.original.as_mut())); + for a in d + .layers + .iter_mut() + .chain(d.symbols.iter_mut().map(|s| &mut s.art)) + .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(patterns.flat_map(|p| &mut p.art)) + { + map_art_colors(Arc::make_mut(a), &mut count); + } + // The reply counts the colors of the document's art only, not those of the graphic styles and brushes. + let in_art = changed.get(); + for it in d.graphic_styles.iter_mut().flat_map(|g| &mut g.appearance.items) { + let paint = match it { + vectorcraft_doc::AppearanceItem::Fill(l) => &mut l.paint, + vectorcraft_doc::AppearanceItem::Stroke(l) => &mut l.paint, + }; + map_paint_colors(paint, &mut count); + } + // Convert each brush's art. A Hue Shift key color takes the converted color of the art color + // it matches (a tint of a global swatch included), so the key still matches the converted art; + // a key that matches no art color converts on its own. + let in_styles = changed.get(); + let mut brushes = vectorcraft_brush::library(d); + let mut keys = false; + for b in &mut brushes { + let old_key = match b.kind.colorization() { + vectorcraft_brush::Colorization::HueShift { key } => Some(key), + _ => None, + }; + let mut new_key = None; + for art in b.art_mut() { + map_art_colors(art, &mut |col: &Color, link: proof::Link| { + let n = count(col, link); + if new_key.is_none() && old_key.is_some_and(|k| matches_key(col, &k)) { + new_key = Some(n); + } + n + }); + } + let colorization = match &mut b.kind { + vectorcraft_brush::BrushKind::Art(a) => Some(&mut a.colorization), + vectorcraft_brush::BrushKind::Scatter(s) => Some(&mut s.colorization), + vectorcraft_brush::BrushKind::Pattern(p) => Some(&mut p.colorization), + vectorcraft_brush::BrushKind::Calligraphic(_) | vectorcraft_brush::BrushKind::Bristle(_) => None, + }; + if let Some(vectorcraft_brush::Colorization::HueShift { key }) = colorization { + let n = new_key.unwrap_or_else(|| conv(key)); + keys |= n != *key; + *key = n; + } + } + if keys || changed.get() > in_styles { + vectorcraft_brush::store(d, &brushes); + } + // This pass gives tint swatches their converted swatch's color at their tint; the passes above already did the same for the art's tints. d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) { Some((_, base)) if *tint < 1.0 => { *c = base.tinted(*tint); @@ -374,7 +435,43 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, } _ => false, }); - changed + in_art +} + +/// [`proof::map_node_colors`] over `n`'s subtree and the art of every opacity mask in it. +fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + proof::map_node_colors(n, f); + map_mask_colors(n, f); +} + +fn map_mask_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + map_art_colors(Arc::make_mut(&mut mask.art), f); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + map_mask_colors(Arc::make_mut(c), f); + } + } +} + +/// Whether art color `c` is the Hue Shift key color `key`, with the tolerance the brush's +/// colorization matches it with. +fn matches_key(c: &Color, key: &Color) -> bool { + c.to_rgb().iter().zip(key.to_rgb()).map(|(a, b)| (a - b).abs()).sum::() < 0.02 +} + +/// `f` applied to a solid paint's color or a gradient's stop colors, given their swatch link. +fn map_paint_colors(p: &mut Paint, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + match p { + Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))), + Paint::Gradient(g) => { + for st in &mut g.gradient.stops { + st.color = f(&st.color, st.swatch.as_deref().map(|s| (s, st.tint))); + } + } + _ => {} + } } fn values(c: &Color) -> Vec { diff --git a/crates/engine/src/cmd/colortheme.rs b/crates/engine/src/cmd/colortheme.rs index 68ec94d6e..9321fc12b 100644 --- a/crates/engine/src/cmd/colortheme.rs +++ b/crates/engine/src/cmd/colortheme.rs @@ -9,7 +9,7 @@ use vectorcraft_color::harmony::{Harmony, THEME_SIZE}; use vectorcraft_color::recolor::ColorKey; use vectorcraft_tools::params::color_json; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, color_value, has_doc, str_param}; /// A saved colour theme ([`crate::Prefs::color_themes`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index d5588ff44..9249a48f4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -5,7 +5,10 @@ use vectorcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObje use vectorcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes}; use super::newart::NewArt; -use super::*; +use super::{ + CommandSpec, NodeId, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, f64_or, f64_req, finite_numbers, has_doc, + number_or, str_param, switch_or, +}; pub fn specs() -> Vec { vec![ @@ -588,11 +591,12 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, } /// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON -/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when +/// whole number of 0 or more, so 4.0 gives 4 and 2.5 gives 5; at most 999) with its skew `key` in percent (0 when /// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]). fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> { check_numbers(p, &[count], cmd)?; - let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + let whole = |v: &Value| v.as_u64().or_else(|| v.as_f64().filter(|f| f.is_finite() && *f >= 0.0 && f.fract() == 0.0).map(|f| f as u64)); + let n = p.get(count).and_then(whole).unwrap_or(5).min(999) as u32; let skew = match p.get(key) { None | Some(Value::Null) => 0.0, Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?, diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index d31656b04..1780cf2be 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index faf905052..90bd276c9 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -15,7 +15,7 @@ use vectorcraft_pathops::BoolOp; use super::draw2::{components, erase_outline, ids_json, replace_with_pieces}; use super::edit::selected_roots; use super::pathops::{Beside, node_path}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, finite_numbers, has_selection, point_param, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index ec1f91f20..a26eee473 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -1,5 +1,5 @@ //! Linked dimensions: generated groups share the existing text/path render and export pipeline. -use super::*; +use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc}; use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 674d44362..3da43957c 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -14,7 +14,10 @@ use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Pl use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, outermost, selected_roots}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, + ids_param, matrix_param, number_or, ok, point_indices, point_param, str_param, switch_or, targets, +}; use crate::EngineError; mod perspective; @@ -195,8 +198,8 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}", - has_doc, + "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again; refused while an opacity mask is edited → {id}", + super::maskedit::outside_mask_editing, edit_text ), ] diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index a655e3e3f..848177027 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, Arc, Document, EngineError, Homography, NodeId, NodeKind, PerspectiveGrid, Plane, Point, Result, Session, Value, bad, check_numbers, + check_switches, collect_points, duplicate_in, finite_numbers, id_param, ids_param, json, matrix_param, number_or, ok, outermost, persp, + point_param, project_node, selected_roots, str_param, switch_or, +}; // ---------- perspective grid ---------- diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 0400ab123..7131dd9d4 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind, RepeatKind, R use vectorcraft_geom::{Affine, Rect}; use vectorcraft_text::embed::Embedding; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, has_selection, str_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 88da38361..05973c9f9 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -12,7 +12,10 @@ use vectorcraft_geom::Vec2; use super::edit::selected_roots; use super::pathops::Beside; use super::select::{selectable_ids, walk_selectable}; -use super::*; +use super::{ + CommandSpec, EngineError, Point, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok, + str_param, +}; pub fn specs() -> Vec { vec![ @@ -231,7 +234,7 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks included), symbols, graph designs, pattern tiles, graphic styles, brushes and swatches through the color settings; swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", has_doc, super::colormgmt::convert_mode ), diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 0dc6fbad3..3f7b63225 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, E use super::flatten::{FlattenOptions, FlattenerPreset}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, color_value, has_doc}; const C: &str = "document.setup"; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 5b884b3ee..dba46b480 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -17,7 +17,10 @@ use vectorcraft_tools::draw2::{eraser_area, eraser_tip}; use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, + switch_or, +}; use crate::EngineError; mod anchors; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 540627fe0..df55e5207 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,5 +1,8 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; -use super::*; +use super::{ + Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, + add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, +}; // ---------- Curvature ---------- @@ -106,13 +109,14 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; + let curvature = switch_or(p, "curvature", false, C)?; let removed = s.edit("Delete Anchor Point", |d, sel| { let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; if ai >= sp.anchors.len() { return Err(EngineError::Other("no such anchor".into())); } let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; + let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?; emptied.finish(d)?; Ok(removed) })?; @@ -138,7 +142,7 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { let shape: Vec<(usize, bool)> = d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); let next = following_anchors(&shape, &anchors); - if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { + if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? { removed += 1; } else if !next.is_empty() { // The next anchor along is selected, so the next Remove Anchor Points goes on from @@ -177,14 +181,16 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr } /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round -/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed -/// through `emptied`. True when the path went. +/// each one, or with `curvature` re-curving it as the Curvature tool curves its points +/// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed, +/// and a path left with no subpath is removed through `emptied`. True when the path went. fn remove_anchors_of( d: &mut Document, sel: &mut Selection, emptied: &mut Beside, id: NodeId, anchors: impl IntoIterator, + curvature: bool, ) -> Result { let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); // The last first, so the indexes still to come stay valid. @@ -193,7 +199,11 @@ fn remove_anchors_of( let path = path_mut(d, id)?; for (si, ai) in anchors { if let Some(sp) = path.subpaths.get_mut(si) { - po::remove_anchor(sp, ai); + if curvature { + vectorcraft_tools::draw2::curvature_remove(sp, ai); + } else { + po::remove_anchor(sp, ai); + } } } path.subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 84337aa27..57066e3b9 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -2,7 +2,7 @@ use super::anchors::{convert_anchor, curvature, curvature_edit, remove_anchor, r use super::cut::{erase_region, erase_segments, knife, split}; use super::freehand::{blob, freehand}; use super::smooth::{join_scrub, smooth_region}; -use super::*; +use super::{CommandSpec, cmd, has_anchors, has_doc, has_selection}; pub fn specs() -> Vec { vec![ @@ -38,7 +38,7 @@ pub fn specs() -> Vec { "Delete Anchor Point", [], None, - "{id, subpath?, anchor} remove one anchor, re-fitting the curve", + "{id, subpath?, anchor, curvature?} remove one anchor, re-fitting the curve (with curvature: true, the curve where it was follows the remaining anchors, as the Curvature tool curves its points)", has_doc, remove_anchor ), @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Blob Brush", [], None, - "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", + "{points: [[x,y]…], size?: pt (10), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", has_doc, blob ), diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index db5d7e372..b8c598a2d 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Cap, Color, Document, EngineError, FillRule, Join, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, + Rect, Result, Selection, Session, SubPath, Value, Vec2, bad, index, json, po, +}; // ---------- helpers ---------- @@ -165,6 +168,33 @@ pub(super) fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec Result> { + let closed_compound = |n: &Node| { + matches!(&n.kind, NodeKind::Compound { children, .. } + if children.iter().all(|c| matches!(&c.kind, NodeKind::Path { path, .. } if path.is_closed()))) + }; + let editable = doc.editable(); + let mut out: Vec<(NodeId, PathData, FillRule)> = vec![]; + for id in target_paths(doc, sel, area) { + let compound = doc.parent_of(id).and_then(|p| doc.node(p)).filter(|p| closed_compound(p) && editable(p.id)); + let target = compound.map_or(id, |c| c.id); + if out.iter().any(|t| t.0 == target) { + continue; + } + let (pd, rule) = match compound.map(|c| &c.kind) { + Some(NodeKind::Compound { children, rule }) => { + (PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect()), *rule) + } + _ => path_of(doc, id)?, + }; + out.push((target, pd, rule)); + } + Ok(out) +} + /// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. fn compound_members(d: &mut Document, pd: PathData) -> Vec> { pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 69306ce8f..e3a566f17 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,7 +1,11 @@ use super::common::{ - dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, + dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, + usize_opt, +}; +use super::{ + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, }; -use super::*; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { @@ -233,8 +237,7 @@ pub(super) fn knife(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { - let (pd, rule) = path_of(&st.doc, id)?; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, 1.0))? { if !pd.is_closed() { continue; } @@ -278,8 +281,7 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { let squash = eraser_tip(angle, roundness).inverse(); let round: Vec = pts.iter().map(|q| squash * *q).collect(); let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { - let (pd, rule) = path_of(&st.doc, id)?; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, r))? { if pd.is_closed() { if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { continue; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index e6df1a60b..e037f0cc1 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -2,7 +2,11 @@ use super::common::{ brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, within_input_bound, }; -use super::*; +use super::{ + Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, + PressureProfile, Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, + str_param, switch_or, +}; // ---------- Pencil / Paintbrush ---------- @@ -333,6 +337,35 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // ---------- Blob Brush ---------- +/// Whether `n` is a path a Blob Brush stroke of `paint` can merge with: the same fill and no stroke. +fn blob_match(n: &Node, paint: &Paint) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.appearance.fill_paint() == *paint && n.appearance.stroke_paint().is_none() +} + +/// The paths of `touched` that a Blob Brush stroke of `paint` merges with: the topmost of them, and +/// the others in the same group or layer that are adjacent to it in stacking order, with no object +/// between them other than a path of the same fill and no stroke. +fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec { + let Some(top) = doc.paint_order(touched.iter().copied()).last().copied() else { + return vec![]; + }; + let Some((parent, at, _)) = doc.position(top) else { + return vec![top]; + }; + let Some(siblings) = doc.children(parent) else { + return vec![top]; + }; + let mut out = vec![top]; + for n in siblings.iter().take(at).rev() { + if touched.contains(&n.id) { + out.push(n.id); + } else if !blob_match(n, paint) { + break; + } + } + out +} + pub(super) fn blob(s: &mut Session, p: &Value) -> Result { const C: &str = "path.blob"; let pts = points_param(p, "points", C)?; @@ -340,7 +373,18 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } - let region = brush_region(&pts, size); + // With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil + // stroke is fitted. Without it, or when the fitted stroke has no length (a short stroke back to + // its start), the brush follows every point. + let fidelity = match p.get("fidelity") { + None | Some(Value::Null) => None, + Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)), + }; + let fitted = fidelity + .filter(|_| pts.len() > 1) + .map(|tol| po::outline_stroke(&PathData::single(fit_freehand(&pts, tol, false)), size, Cap::Round, Join::Round, 4.0)) + .filter(|r| !r.is_empty()); + let region = fitted.unwrap_or_else(|| brush_region(&pts, size)); if region.is_empty() { return Err(EngineError::Other("empty brush stroke".into())); } @@ -359,9 +403,11 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if merge && !(only_selection && st.selection.is_empty()) { let all = Selection::default(); let from = if only_selection { &st.selection } else { &all }; - for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { + // A fitted stroke can curve outside the points' bounds, so the area also takes in the shape. + let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, size), |b| b.union(poly_bounds(&pts, size))); + for id in target_paths(&st.doc, from, area) { let Some(n) = st.doc.node(id) else { continue }; - if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { + if !blob_match(n, &paint) { continue; } let (pd, rule) = path_of(&st.doc, id)?; @@ -369,6 +415,8 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { merge_ids.push((id, pd, rule)); } } + let joining = adjacent_to_topmost(&st.doc, &merge_ids.iter().map(|m| m.0).collect::>(), &paint); + merge_ids.retain(|m| joining.contains(&m.0)); } let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); if merge_ids.is_empty() { @@ -377,11 +425,10 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; - let Some(((keep, _, _), rest)) = merge_ids.split_last() else { + let Some(keep) = st.doc.paint_order(merge_ids.iter().map(|m| m.0)).last().copied() else { return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); }; - let keep = *keep; - let others: Vec = rest.iter().map(|m| m.0).collect(); + let others: Vec = merge_ids.iter().map(|m| m.0).filter(|id| *id != keep).collect(); s.edit("Blob Brush", |d, sel| { *path_mut(d, keep)? = merged; remove_each(d, &others)?; diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index e20665447..fd783aa4f 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -1,5 +1,8 @@ use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; -use super::*; +use super::{ + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + number_or, po, +}; // ---------- Smooth ---------- diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fa4878d2c..a2cab84a8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,7 +8,11 @@ use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; -use super::*; +use super::{ + CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req, + finite_numbers, has_clipboard, has_doc, has_selection, has_selection_or_artboard_tool, has_selection_or_guides, id_param, index, ok, point_param, + str_param, +}; use crate::{Clipboard, EngineError, inspect}; /// Register editing commands, including document-to-document artwork drops and their parameter contracts. @@ -38,6 +42,15 @@ pub fn specs() -> Vec { has_doc, set_units ), + cmd!( + "document.switchUnits", + "Switch Units", + [], + Some("Cmd+Alt+Shift+U"), + "{} the document's units become the next of Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards and Feet (the order a right-click on a ruler lists them), Feet going back to Points; one undo step → {units}", + has_doc, + switch_units + ), cmd!( query "document.rulerOrigin", "Ruler Origin", @@ -328,6 +341,14 @@ fn set_units(s: &mut Session, p: &Value) -> Result { ok() } +/// Switch Units: the unit after the document's in [`Unit::ALL`], wrapping around. +fn switch_units(s: &mut Session, _: &Value) -> Result { + let now = s.doc()?.doc.units; + let next = Unit::ALL.iter().position(|u| *u == now).and_then(|i| Unit::ALL.get((i + 1) % Unit::ALL.len())).copied().unwrap_or_default(); + s.set_document_units(next)?; + Ok(json!({ "units": next.label() })) +} + /// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { let index = match p.get("artboard") { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e91a621c4..933acc41b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -15,7 +15,7 @@ use vectorcraft_render::effects; use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_target, item_target_at, shared_stack}; use super::expand::has_seeded; use super::rasterfx; -use super::*; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index efcccd241..a62d5b18f 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -22,7 +22,7 @@ use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_selection, str_param}; const C: &str = "object.expand"; /// Expand Gradient To → Specify … Objects: the default and the most. @@ -131,7 +131,11 @@ impl Expandable { e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); - e.stroke |= shape && !c.appearance.stroke_paint().is_none(); + e.stroke |= match &c.kind { + // Type's outlines take its characters' stroke (see `type.createOutlines`). + NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), + _ => shape && !c.appearance.stroke_paint().is_none(), + }; e.gradient |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 549d0982c..36ccd7cdb 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document}; -use super::*; +use super::{Result, Session, bad}; const C: &str = "file.info"; /// Longest text field, in characters. diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6e5c9402e..6893c6f84 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use svg::options_map as svg_options; #[cfg(not(target_arch = "wasm32"))] pub use vectorcraft_format::write_atomic; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, has_selection}; use crate::EngineError; mod access; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 8dfbead2d..ffe34acfd 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::write_atomic; +use super::{EngineError, Result, Session, Value, bad, json}; // ---------- the file system (none on the web, where commands take and return bytes) ---------- // diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 90856aaa4..d1db60302 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -9,7 +9,9 @@ use serde_json::Value; #[cfg(not(target_arch = "wasm32"))] use serde_json::json; -use super::super::*; +use super::super::{Result, Session, bad}; +#[cfg(not(target_arch = "wasm32"))] +use super::super::{bool_or, find_command, str_param}; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs index 0d74d1d81..e87e9ac0f 100644 --- a/crates/engine/src/cmd/fileio/affinity/document.rs +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -1,7 +1,7 @@ use super::layout::{master_instances, master_prefix, page_masters}; use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost}; use super::nodes::rect_of; -use super::*; +use super::{Affine, Arc, Artboard, Blend, Builder, Document, Kind, Node, NodeKind, Result, af, err}; impl Builder { pub(super) fn new(k: f64) -> Self { diff --git a/crates/engine/src/cmd/fileio/affinity/import.rs b/crates/engine/src/cmd/fileio/affinity/import.rs index 625df1e3b..0b0d587d8 100644 --- a/crates/engine/src/cmd/fileio/affinity/import.rs +++ b/crates/engine/src/cmd/fileio/affinity/import.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Builder, Cursor, Document, Result, err, raster_doc}; /// What an Affinity file opened as. pub(in super::super) struct Imported { diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs index 157a38836..8b6d4c4a8 100644 --- a/crates/engine/src/cmd/fileio/affinity/layout.rs +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -1,5 +1,5 @@ use super::nodes::MAX_DEPTH; -use super::*; +use super::{AfAlign, CharStyle, Cursor, Justify, Kind, Rect, TextKind, TextObject, VerticalAlign, af, shapes}; /// The master page instances among `nodes` (also inside layers and groups): (master, first page, /// first master page, page count). diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs index bc55dbc25..91b0ce53c 100644 --- a/crates/engine/src/cmd/fileio/affinity/master_copies.rs +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -1,5 +1,5 @@ use super::nodes::MAX_DEPTH; -use super::*; +use super::{Kind, af}; /// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index a3867d0bd..0375ff39e 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, Blend, BlendMode, Builder, FillRule, Kind, LayerColor, Node, NodeKind, OpacityMask, Rect, af}; /// Deepest node tree mapped (the reader bounds trees well below this). pub(super) const MAX_DEPTH: usize = 160; diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs index a60911e0d..ce798dcfb 100644 --- a/crates/engine/src/cmd/fileio/affinity/paint.rs +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -1,7 +1,11 @@ use super::layout::png; use super::nodes::affine; use super::text::Placement; -use super::*; +use super::{ + AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind, + GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer, + TextObject, Vec2, af, ap, json, +}; impl Builder { /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's diff --git a/crates/engine/src/cmd/fileio/affinity/text.rs b/crates/engine/src/cmd/fileio/affinity/text.rs index cae76dc7b..beaaac541 100644 --- a/crates/engine/src/cmd/fileio/affinity/text.rs +++ b/crates/engine/src/cmd/fileio/affinity/text.rs @@ -1,6 +1,9 @@ use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell}; use super::nodes::affine; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, CharStyle, FillRule, FirstBaseline, Node, NodeKind, Paint, PathData, Point, Rect, + TextKind, TextObject, TextRun, Vec2, VerticalAlign, af, ap, shapes, +}; /// Where text goes: a rigid placement (translation and rotation) and the scale that maps node /// space into it (applied to the frame's geometry, not the type), plus the type's own scale. diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index ff8b8aece..3dc3a51d9 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::Document; -use super::super::*; +use super::super::{Result, Session, bad, str_param}; use crate::{DocState, EngineError}; pub(super) fn batch(s: &mut Session, p: &Value) -> Result { @@ -27,15 +27,19 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let active = s.active; s.batch_stash = Some(vec![]); let mut grouped = Vec::with_capacity(cmds.len()); + // The Type tool keeps what the steps' Undo and Redo change in it, to put back on a rollback. + s.notify_text_tool("batch.begin"); let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped); let stash = s.batch_stash.take().unwrap_or_default(); let results = match r { Ok(results) => { + s.notify_text_tool("batch.end"); s.batch_steps = Some(grouped); results } Err(e) => { roll_back(s, start, &stash); + s.notify_text_tool("batch.rollBack"); s.active = active; (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved; s.pending_paint = None; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0a0a8549f..0c38079df 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -1,6 +1,6 @@ use super::access::file_list; use super::formats::formats; -use super::*; +use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, save, screens}; pub fn specs() -> Vec { vec![ @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,