diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..49e1685c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# DrawCraft — instructions for agents + +DrawCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions. + +## Start every session here +1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`. +2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. + +`plan/` is gitignored (local only). + +## Non-negotiables +- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…). +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. +- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. +- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). + +## Running and looking at the app +- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` +- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. +- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. +- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`). +- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..49e1685c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# DrawCraft — instructions for agents + +DrawCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions. + +## Start every session here +1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`. +2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. + +`plan/` is gitignored (local only). + +## Non-negotiables +- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…). +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. +- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. +- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). + +## Running and looking at the app +- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` +- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. +- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. +- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`). +- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. diff --git a/Cargo.lock b/Cargo.lock index 9951aed0..2b22c3ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,10 @@ dependencies = [ [[package]] name = "drawcraft-cli" version = "0.1.0" +dependencies = [ + "drawcraft-mcp", + "serde_json", +] [[package]] name = "drawcraft-color" @@ -883,6 +887,17 @@ dependencies = [ [[package]] name = "drawcraft-mcp" version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "drawcraft-engine", + "drawcraft-format", + "drawcraft-geom", + "drawcraft-render", + "drawcraft-svg", + "drawcraft-tools", + "log", + "serde_json", +] [[package]] name = "drawcraft-pathops" @@ -988,6 +1003,18 @@ dependencies = [ [[package]] name = "drawcraft-web" version = "0.1.0" +dependencies = [ + "drawcraft-engine", + "drawcraft-ui-egui", + "eframe", + "egui", + "js-sys", + "log", + "rfd", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "ecolor" diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..3130911b --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ +DrawCraft +Copyright the DrawCraft contributors. Licensed under MIT OR Apache-2.0. + +This product bundles third-party material under the following licences: + +- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt. +- DrawCraft icons (assets/icons/dc-*.svg) — original work, MIT OR Apache-2.0. +- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt. +- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. +- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. + +Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are +permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/README.md b/README.md index c9ca187b..52eba9be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,19 @@ DrawCraft ========= -By the artcraft team +By the artcraft team. + +A fast, open-source, cross-platform vector illustration app written in pure Rust — a clean-room +reimplementation of the Adobe Illustrator workflow (tools, panels, menus, shortcuts) that runs on +macOS, Windows, Linux and the web (WASM), and is fully drivable by agents over a control channel +and MCP. + +```sh +cargo run --release -p drawcraft # desktop app +cargo run --release -p drawcraft -- --control 7979 # + JSON control channel +cargo run --release -p drawcraft-cli -- mcp # MCP server (stdio) +cargo xtask ci # fmt, clippy, tests, layering, wasm +``` + +Workspace: `crates/{geom,color,doc,pathops,text,effects,render,svg,pdf,format,tools,engine,ui-egui,mcp,testkit}`, +`apps/{drawcraft,drawcraft-cli,drawcraft-web}`. See `CLAUDE.md` and `docs/`. diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml index 60dd2088..70c358e1 100644 --- a/apps/drawcraft-cli/Cargo.toml +++ b/apps/drawcraft-cli/Cargo.toml @@ -4,8 +4,15 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false +description = "DrawCraft command line: MCP server (stdio) and headless batch runs" + +[[bin]] +name = "drawcraft-cli" +path = "src/main.rs" [lints] workspace = true [dependencies] +drawcraft-mcp = { workspace = true } +serde_json = { workspace = true } diff --git a/apps/drawcraft-web/Cargo.toml b/apps/drawcraft-web/Cargo.toml index c1a77c39..1b97ae73 100644 --- a/apps/drawcraft-web/Cargo.toml +++ b/apps/drawcraft-web/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "drawcraft-web" +description = "DrawCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true license.workspace = true @@ -8,4 +9,21 @@ publish = false [lints] workspace = true +[[bin]] +name = "drawcraft-web" +path = "src/main.rs" + [dependencies] +drawcraft-engine = { workspace = true } +drawcraft-ui-egui = { workspace = true } +egui = { workspace = true } +log = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +rfd = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/drawcraft-web/Trunk.toml b/apps/drawcraft-web/Trunk.toml new file mode 100644 index 00000000..5a4fff4e --- /dev/null +++ b/apps/drawcraft-web/Trunk.toml @@ -0,0 +1,10 @@ +# Build with: trunk build --release (from apps/drawcraft-web) +[build] +target = "index.html" +dist = "../../dist/web" +public_url = "./" +filehash = true + +[serve] +addresses = ["127.0.0.1"] +port = 8766 diff --git a/apps/drawcraft-web/index.html b/apps/drawcraft-web/index.html new file mode 100644 index 00000000..beb2938a --- /dev/null +++ b/apps/drawcraft-web/index.html @@ -0,0 +1,19 @@ + + + + + + DrawCraft + + + + + +
Loading DrawCraft…
+ + diff --git a/apps/drawcraft-web/src/main.rs b/apps/drawcraft-web/src/main.rs index f328e4d9..b0f087d4 100644 --- a/apps/drawcraft-web/src/main.rs +++ b/apps/drawcraft-web/src/main.rs @@ -1 +1,27 @@ -fn main() {} +//! DrawCraft in the browser. +//! +//! Runs the same [`drawcraft_ui_egui::DrawcraftApp`] as the desktop app through eframe's web +//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` +//! from this directory; see `docs/development.md` ("Web build"). +//! +//! Differences from the desktop app: +//! - no TCP control server (browsers can't listen on sockets); +//! - File → Open uses the browser file picker; bytes arrive asynchronously through +//! `Services::inbox`; +//! - Save / Export trigger a browser download; +//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. + +#[cfg(target_arch = "wasm32")] +mod web; + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("drawcraft-web only runs in the browser: build it with `trunk build --release` in apps/drawcraft-web"); +} diff --git a/apps/drawcraft-web/src/web.rs b/apps/drawcraft-web/src/web.rs new file mode 100644 index 00000000..d8925911 --- /dev/null +++ b/apps/drawcraft-web/src/web.rs @@ -0,0 +1,171 @@ +//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. + +use std::sync::{Arc, Mutex}; + +use drawcraft_engine::Session; +use drawcraft_ui_egui::{DrawcraftApp, Services}; +use wasm_bindgen::JsCast as _; + +type Inbox = Arc)>>>; + +const OPEN_EXTS: &[&str] = &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]; +const CANVAS_ID: &str = "drawcraft_canvas"; +const LOADING_ID: &str = "drawcraft_loading"; + +pub fn start() { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + wasm_bindgen_futures::spawn_local(async { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { + log::error!("no document"); + return; + }; + let Some(canvas) = document + .get_element_by_id(CANVAS_ID) + .and_then(|e| e.dyn_into::().ok()) + else { + log::error!("missing "); + return; + }; + let mut options = eframe::WebOptions::default(); + if query().contains("webgl") + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = eframe::wgpu::Backends::GL; + } + let result = eframe::WebRunner::new() + .start( + canvas, + options, + Box::new(move |cc| { + if let Some(rs) = &cc.wgpu_render_state { + log::info!("drawcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); + } + let inbox: Inbox = Arc::default(); + let app = DrawcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); + Ok(Box::new(WebShell { app, inbox })) + }), + ) + .await; + if let Some(el) = document.get_element_by_id(LOADING_ID) { + match result { + Ok(()) => el.remove(), + Err(e) => el.set_inner_html(&format!( + "

DrawCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

" + )), + } + } + }); +} + +fn query() -> String { + web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() +} + +/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) +/// and feed them through the inbox. +struct WebShell { + app: DrawcraftApp, + inbox: Inbox, +} + +impl eframe::App for WebShell { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let inbox = self.inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f + .path() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + self.app.logic(ctx); + } + + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(raw); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.app.ui(ui); + } +} + +fn services(inbox: Inbox, ctx: egui::Context) -> Services { + let open_inbox = inbox.clone(); + Services { + open_async: Some(Box::new(move || { + let inbox = open_inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else { + return; + }; + let bytes = file.read().await; + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + ctx.request_repaint(); + }); + })), + download: Some(Box::new(|name: &str, bytes: &[u8]| { + if let Err(e) = download(name, bytes) { + log::error!("download of {name} failed: {e}"); + } + })), + inbox: Some(inbox), + ..Default::default() + } +} + +/// Trigger a browser download of `bytes` named after the last component of `path`. +fn download(path: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "drawcraft".into()); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime_for(&name)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let a: web_sys::HtmlAnchorElement = + document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + a.set_href(&url); + a.set_download(&name); + a.style().set_property("display", "none").map_err(js)?; + let body = document.body().ok_or("no body")?; + body.append_child(&a).map_err(js)?; + a.click(); + a.remove(); + // Revoke after the click has been dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + web_sys::Url::revoke_object_url(&url).ok(); + }); + window + .set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000) + .map_err(js)?; + Ok(()) +} + +fn mime_for(name: &str) -> &'static str { + match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("pdf") => "application/pdf", + Some("drawcraft") => "application/json", + _ => "application/octet-stream", + } +} diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs new file mode 100644 index 00000000..67b20c28 --- /dev/null +++ b/crates/engine/src/cmd/pathops.rs @@ -0,0 +1,710 @@ +//! Pathfinder panel (`object.pathfinder.*`) and Object → Path commands backed by +//! `drawcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid, +//! Clean Up and Divide Objects Below. + +use std::sync::Arc; + +use drawcraft_color::{BlendMode, Paint}; +use drawcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; +use drawcraft_geom::{FillRule, PathData}; +use drawcraft_pathops as po; +use drawcraft_pathops::{BoolOp, PathfinderOp}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + const PF: &str = "{} operate on the selected objects (back → front) → {ids}"; + vec![ + cmd!("object.pathfinder.unite", "Unite", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Unite)), + cmd!("object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( + s, + PathfinderOp::MinusFront + )), + cmd!("object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( + s, + PathfinderOp::Intersect + )), + cmd!("object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Exclude)), + cmd!("object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Divide)), + cmd!("object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Trim)), + cmd!("object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Merge)), + cmd!("object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, PF, has_multi, |s, _| run_pf(s, PathfinderOp::Crop)), + cmd!("object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Outline)), + cmd!("object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( + s, + PathfinderOp::MinusBack + )), + cmd!( + "object.path.outlineStroke", + "Outline Stroke", + ["Object", "Path"], + None, + "{} replace stroked paths with filled outlines → {ids}", + has_selection, + outline_stroke + ), + cmd!( + "object.path.offsetPath", + "Offset Path…", + ["Object", "Path"], + None, + "{offset: pt (negative insets), joins?: \"miter\"|\"round\"|\"bevel\", miterLimit?: 4} → {ids} (new objects above the originals)", + has_selection, + offset_path + ), + cmd!( + "object.path.simplify", + "Simplify…", + ["Object", "Path"], + None, + "{tolerance?: pt (1), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts", + has_selection, + simplify + ), + cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points), + cmd!( + "object.path.divideObjectsBelow", + "Divide Objects Below", + ["Object", "Path"], + None, + "{} cut the objects below with the selected path (which is removed) → {ids}", + has_selection, + divide_objects_below + ), + cmd!( + "object.path.splitIntoGrid", + "Split Into Grid…", + ["Object", "Path"], + None, + "{rows?: 2, columns?: 2, gutter?: pt (12)} → {ids}", + has_selection, + split_into_grid + ), + cmd!( + "object.path.cleanUp", + "Clean Up…", + ["Object", "Path"], + None, + "{strayPoints?: true, unpaintedObjects?: true, emptyTextPaths?: true} → {removed}", + has_doc, + clean_up + ), + ] +} + +// ---------- helpers ---------- + +/// Number param that also accepts strings like `"10 pt"`. +pub(crate) fn num_param(p: &Value, key: &str) -> Option { + match p.get(key)? { + Value::Number(n) => n.as_f64(), + Value::String(s) => { + let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); + t.parse().ok() + } + _ => None, + } + .filter(|v: &f64| v.is_finite()) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Paint identity carried through a Pathfinder operation. +#[derive(Clone, Debug, PartialEq)] +struct Style { + appearance: Appearance, + opacity: f32, + blend: BlendMode, +} + +impl Style { + fn of(n: &Node) -> Self { + Style { appearance: n.appearance.clone(), opacity: n.opacity, blend: n.blend } + } + fn apply(&self, n: &mut Node) { + n.appearance = self.appearance.clone(); + n.opacity = self.opacity; + n.blend = self.blend; + } +} + +/// All subpaths of a path or compound path node, in document coordinates, with its fill rule. +pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + let mut subs = vec![]; + for c in children { + if let Some((p, _)) = node_path(c) { + subs.extend(p.subpaths); + } + } + Some((PathData::new(subs), *rule)) + } + NodeKind::Text(t) => Some((text_outline(t), FillRule::NonZero)), + _ => None, + } +} + +/// Glyph outlines of a text object in document coordinates. +pub(crate) fn text_outline(t: &TextObject) -> PathData { + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf) +} + +/// Appearance a text object's outlines take: the first run's fill and stroke. +pub(crate) fn text_style_appearance(fill: &Paint, stroke: &Paint, width: f64) -> Appearance { + let mut a = Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(fill.clone()))], effects: vec![] }; + if !stroke.is_none() && width > 0.0 { + a.items.push(AppearanceItem::Stroke(StrokeLayer::new(stroke.clone(), width))); + } + a +} + +fn style_of(n: &Node) -> Style { + match &n.kind { + NodeKind::Text(t) => { + let st = t.first_style(); + Style { appearance: text_style_appearance(&st.fill, &st.stroke, st.stroke_width), opacity: n.opacity, blend: n.blend } + } + _ => Style::of(n), + } +} + +/// Leaf shapes (paths, compound paths, text) of a subtree in paint order; guides and clipping +/// paths are skipped. +fn leaves(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => out.push(n.clone()), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + leaves(c, out); + } + } + _ => {} + } +} + +/// A node for `path`: a plain path for one subpath, else a compound path. +pub(crate) fn shape_node(d: &mut Document, path: PathData, style_src: Option<&Node>) -> Node { + let id = d.alloc_id(); + let mut n = if path.subpaths.len() <= 1 { + Node::path(id, path, Appearance::default()) + } else { + let children = path + .subpaths + .into_iter() + .map(|sp| { + let cid = d.alloc_id(); + Arc::new(Node::path(cid, PathData::single(sp), Appearance::default())) + }) + .collect(); + Node::new(id, NodeKind::Compound { children, rule: FillRule::NonZero }) + }; + if let Some(src) = style_src { + Style::of(src).apply(&mut n); + } + n +} + +fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node { + let mut n = shape_node(d, path, None); + style.apply(&mut n); + n +} + +// ---------- Pathfinder ---------- + +fn pf_label(op: PathfinderOp) -> &'static str { + match op { + PathfinderOp::Unite => "Unite", + PathfinderOp::MinusFront => "Minus Front", + PathfinderOp::Intersect => "Intersect", + PathfinderOp::Exclude => "Exclude", + PathfinderOp::Divide => "Divide", + PathfinderOp::Trim => "Trim", + PathfinderOp::Merge => "Merge", + PathfinderOp::Crop => "Crop", + PathfinderOp::Outline => "Outline", + PathfinderOp::MinusBack => "Minus Back", + } +} + +fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { + let roots = selected_roots(s)?; + let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) }; + let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); + let label = pf_label(op); + let ids = s.edit(label, |d, sel| { + // Build the stack (back → front) with a style per entry. + let mut styles: Vec