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..8365b32a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,7 +1310,6 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "image", @@ -6036,7 +6035,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6077,7 +6076,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.17.0" +version = "0.18.0" dependencies = [ "crc32fast", "flate2", @@ -6088,7 +6087,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6100,7 +6099,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6116,7 +6115,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "log", "objc2-core-foundation", @@ -6138,7 +6137,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6150,7 +6149,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6169,7 +6168,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6185,7 +6184,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "crc32fast", @@ -6224,7 +6223,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "flate2", @@ -6248,7 +6247,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "proptest", @@ -6263,7 +6262,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6275,7 +6274,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "log", @@ -6294,7 +6293,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6308,7 +6307,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6320,12 +6319,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 +6351,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.17.0" +version = "0.18.0" dependencies = [ "serde", "serde_json", @@ -6365,7 +6365,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "gif", @@ -6394,14 +6394,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 +6426,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "image", @@ -6444,7 +6444,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.17.0" +version = "0.18.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.17.0" +version = "0.18.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6473,7 +6473,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6539,7 +6539,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "eframe", "egui", @@ -7670,7 +7670,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..bf358b2d7 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 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..d01d24eb5 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] diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index c7d6ada32..3161d1a37 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,5 +1,7 @@ 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}; /// 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 +12,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 +43,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) { @@ -65,15 +71,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..0b228f62c 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 { diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index aa7d7f42c..014782ed4 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,4 +1,10 @@ -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}; /// The window file dialogs belong to. pub(crate) type Parent = Option>; @@ -266,7 +272,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/main.rs b/apps/vectorcraft/src/main.rs index 2cdf80b42..c37aebe96 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")] @@ -50,7 +43,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 +183,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 +288,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 +429,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()); 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/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/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/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 9d17f689a..c0c85b124 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, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice, + SymbolRegistration, +}; 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/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..61b0b91a9 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::mesh::GradientMesh; use super::warp::map_nonlinear; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, 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>; 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/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..468930348 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`]). 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..0994cf390 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)] 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/wire.rs b/crates/doc/src/node/wire.rs index 43fcfea92..0af451316 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`. 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/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 c7d6e943d..c9ced2cf0 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 6cdae3142..0bf62ed15 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..5ad48a1ed 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)] 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..f3c5b3e5b 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; 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..c7a7c39b5 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -27,7 +27,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; 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..068146a2a 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_roots, 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"; 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..5c31deb1c 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). 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..9b008500a 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; 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..f1cd09107 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![ 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, ,