diff --git a/Cargo.lock b/Cargo.lock index 2b22c3ac..e34ead70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,6 +705,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -914,9 +923,17 @@ dependencies = [ name = "drawcraft-pdf" version = "0.1.0" dependencies = [ + "drawcraft-color", "drawcraft-doc", "drawcraft-geom", + "drawcraft-text", + "hayro-interpret", + "hayro-syntax", + "image", + "krilla", "kurbo 0.13.1", + "log", + "smallvec", "thiserror 2.0.21", ] @@ -926,10 +943,13 @@ version = "0.1.0" dependencies = [ "drawcraft-color", "drawcraft-doc", + "drawcraft-effects", "drawcraft-geom", "drawcraft-text", "image", "log", + "serde_json", + "vello_common 0.2.0", "vello_cpu 0.2.0", ] @@ -1224,7 +1244,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.5", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1344,6 +1364,9 @@ name = "float-cmp" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] [[package]] name = "fnv" @@ -1357,6 +1380,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.5" @@ -1715,6 +1747,81 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo 0.13.1", + "moxcms", + "phf", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.42.1", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1762,6 +1869,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "imagesize" version = "0.15.0" @@ -1889,6 +2002,34 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "krilla" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +dependencies = [ + "base64 0.22.1", + "bumpalo", + "flate2", + "float-cmp", + "gif", + "image-webp", + "imagesize 0.14.0", + "indexmap", + "pdf-writer", + "png 0.18.1", + "rustc-hash 2.1.3", + "rustybuzz", + "siphasher", + "skrifa 0.42.1", + "smallvec", + "subsetter", + "tiny-skia-path 0.12.0", + "xmp-writer", + "yoke", + "zune-jpeg", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -2534,6 +2675,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags 2.13.2", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "peniko" version = "0.6.1" @@ -2895,6 +3048,16 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -2902,7 +3065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", "once_cell", ] @@ -2913,7 +3076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", "once_cell", ] @@ -2924,7 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", "once_cell", ] @@ -3097,6 +3260,30 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3237,6 +3424,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -3362,6 +3559,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3377,6 +3580,18 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo 0.13.1", + "rustc-hash 2.1.3", + "skrifa 0.42.1", + "write-fonts", +] + [[package]] name = "svgtypes" version = "0.15.3" @@ -3419,6 +3634,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3609,6 +3835,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "type-map" version = "0.5.1" @@ -3641,6 +3876,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3653,6 +3900,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-script" version = "0.5.8" @@ -4578,6 +4831,19 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap", + "kurbo 0.13.1", + "log", + "read-fonts 0.39.2", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -4647,6 +4913,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xmp-writer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" + [[package]] name = "xtask" version = "0.1.0" @@ -4654,6 +4926,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zbus" version = "5.19.0" @@ -4780,6 +5075,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/apps/drawcraft-web/src/web.rs b/apps/drawcraft-web/src/web.rs index d8925911..68c065dc 100644 --- a/apps/drawcraft-web/src/web.rs +++ b/apps/drawcraft-web/src/web.rs @@ -19,10 +19,7 @@ pub fn start() { log::error!("no document"); return; }; - let Some(canvas) = document - .get_element_by_id(CANVAS_ID) - .and_then(|e| e.dyn_into::().ok()) - else { + let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); return; }; @@ -49,9 +46,7 @@ pub fn start() { 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.

" - )), + Err(e) => el.set_inner_html(&format!("

DrawCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), } } }); @@ -75,11 +70,7 @@ impl eframe::App for WebShell { 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()); + 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)); @@ -129,10 +120,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { /// 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 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)); @@ -140,8 +128,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { 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")?; + 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)?; @@ -153,9 +140,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { 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)?; + window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; Ok(()) } diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index af8a6548..3457d9d3 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -30,7 +30,12 @@ impl eframe::App for App { fn services() -> Services { Services { - pick_open: Some(Box::new(|| rfd::FileDialog::new().add_filter("All supported", &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]).pick_file().map(|p| p.to_string_lossy().to_string()))), + pick_open: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter("All supported", &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs new file mode 100644 index 00000000..32fef093 --- /dev/null +++ b/crates/effects/src/distort.rs @@ -0,0 +1,232 @@ +//! Distort & Transform effects. + +use drawcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use serde_json::Value; + +use crate::util::*; + +fn diag(b: Rect) -> f64 { + b.width().hypot(b.height()).max(1e-6) +} + +/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates). +pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData { + let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; + if let Some(arr) = p.get("corners").and_then(Value::as_array) { + for (i, v) in arr.iter().take(4).enumerate() { + if let Some(a) = v.as_array() + && let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) + && x.is_finite() + && y.is_finite() + { + c[i] = Point::new(x, y); + } + } + } + let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); + let to_doc = |q: Point| Point::new(b.x0 + q.x * w, b.y0 + q.y * h); + let c = c.map(to_doc); + let f = |q: Point| { + let u = (q.x - b.x0) / w; + let v = (q.y - b.y0) / h; + let top = c[0].lerp(c[1], u); + let bot = c[3].lerp(c[2], u); + top.lerp(bot, v) + }; + map_nonlinear(path, diag(b) / 16.0, f) +} + +/// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the +/// opposite way. +pub fn pucker_bloat(path: &PathData, b: Rect, amount: f64) -> PathData { + let a = amount.clamp(-200.0, 200.0) / 100.0; + if a == 0.0 { + return path.clone(); + } + let c = b.center(); + // Lines have retracted handles: give them handles at 1/3 before distorting so they bulge. + { + let mut res = path.clone(); + for (si, sp) in res.subpaths.iter_mut().enumerate() { + let n = sp.anchors.len(); + let segs = sp.segment_count(); + let orig = &path.subpaths[si]; + for i in 0..segs { + let cub = seg_cubic(orig, i); + let j = (i + 1) % n; + sp.anchors[i].h_out = cub.p1; + sp.anchors[j].h_in = cub.p2; + } + for an in &mut sp.anchors { + let p = an.p; + *an = Anchor::with_handles(p.lerp(c, a * 0.5), an.h_in.lerp(c, -a * 0.5), an.h_out.lerp(c, -a * 0.5)); + } + } + res + } +} + +/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. +pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData { + let size = num(p, "size", 5.0).clamp(0.0, 1e4); + let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; + let detail = num(p, "detail", 10.0).clamp(0.0, 100.0); + let spacing = if detail > 0.0 { 72.0 / detail } else { f64::INFINITY }; + let smooth = smooth_points(p); + let seed = seed(p); + let mut subs = vec![]; + for (si, sp) in path.subpaths.iter().enumerate() { + let mut pts = vec![]; + for i in 0..sp.segment_count() { + let c = sp.segment(i); + let len = drawcraft_geom::kurbo::ParamCurveArclen::arclen(&c, 0.1); + let k = if spacing.is_finite() { ((len / spacing).ceil() as usize).clamp(1, 2000) } else { 1 }; + for j in 0..k { + pts.push(c.eval(j as f64 / k as f64)); + } + } + if !sp.closed + && let Some(l) = sp.anchors.last() + { + pts.push(l.p); + } + if pts.is_empty() { + pts.extend(sp.anchors.iter().map(|a| a.p)); + } + for (k, q) in pts.iter_mut().enumerate() { + q.x += amp * noise(seed, si as u64, k as u64, 1); + q.y += amp * noise(seed, si as u64, k as u64, 2); + } + subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) }); + } + PathData::new(subs) +} + +/// Transform effect (scale, move, rotate, reflect about the centre, plus copies). +pub fn transform(path: &PathData, b: Rect, p: &Value) -> PathData { + let sx = num(p, "scaleH", 100.0) / 100.0; + let sy = num(p, "scaleV", 100.0) / 100.0; + let (rx, ry) = (if flag(p, "reflectX", false) { -1.0 } else { 1.0 }, if flag(p, "reflectY", false) { -1.0 } else { 1.0 }); + let c = b.center().to_vec2(); + // Reflect X mirrors across the horizontal axis (flips x coordinates in Illustrator's dialog). + let m = Affine::translate(Vec2::new(num(p, "moveH", 0.0), num(p, "moveV", 0.0)) + c) + * Affine::rotate(-num(p, "rotate", 0.0).to_radians()) + * Affine::scale_non_uniform(sx * rx, sy * ry) + * Affine::translate(-c); + let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize; + if copies == 0 { + return path.transformed(m); + } + let mut out = path.clone(); + let mut cur = path.clone(); + for _ in 0..copies { + cur = cur.transformed(m); + out.subpaths.extend(cur.subpaths.iter().cloned()); + } + out +} + +/// Tweak: random displacement of anchors and/or control points. +pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData { + let rel = flag(p, "relative", true); + let h = num(p, "h", 10.0); + let v = num(p, "v", 10.0); + let (ah, av) = if rel { (h / 100.0 * b.width().abs(), v / 100.0 * b.height().abs()) } else { (h, v) }; + let (anchors, ins, outs) = (flag(p, "anchors", true), flag(p, "in", true), flag(p, "out", true)); + let seed = seed(p); + let mut out = path.clone(); + for (si, sp) in out.subpaths.iter_mut().enumerate() { + for (ai, an) in sp.anchors.iter_mut().enumerate() { + let key = ((si as u64) << 32) | ai as u64; + let d = |c: u64| Vec2::new(ah * noise(seed, key, c, 1), av * noise(seed, key, c, 2)); + let (mut pp, mut hi, mut ho) = (an.p, an.h_in, an.h_out); + if anchors { + let dp = d(0); + pp += dp; + hi += dp; + ho += dp; + } + if ins { + hi += d(1); + } + if outs { + ho += d(2); + } + *an = Anchor::with_handles(pp, hi, ho); + } + } + out +} + +/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle. +pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { + let ang = angle.clamp(-3600.0, 3600.0).to_radians(); + if ang == 0.0 { + return path.clone(); + } + let c = b.center(); + let r = diag(b) / 2.0; + let f = move |q: Point| { + let d = q - c; + let t = (1.0 - d.hypot() / r).max(0.0); + let a = -ang * t; + let (s, co) = a.sin_cos(); + c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) + }; + let pieces = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0)); + map_nonlinear(path, pieces, f) +} + +/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal. +pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { + let size = num(p, "size", 10.0).clamp(-1e4, 1e4); + let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size }; + let ridges = num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize; + let smooth = smooth_points(p); + let mut subs = vec![]; + for sp in &path.subpaths { + let segs = sp.segment_count(); + let mut pts = vec![]; + let mut k = 0usize; + for i in 0..segs { + let c = sp.segment(i); + for j in 0..=ridges { + let t = j as f64 / (ridges + 1) as f64; + let nrm = if j == 0 { + // Average the normals of the segments meeting at the anchor. + let prev = if i > 0 { + Some(sp.segment(i - 1)) + } else if sp.closed { + Some(sp.segment(segs - 1)) + } else { + None + }; + let n1 = normal_at(&c, 0.0); + match prev { + Some(pc) => { + let s = n1 + normal_at(&pc, 1.0); + if s.hypot() > 1e-9 { s / s.hypot() } else { n1 } + } + None => n1, + } + } else { + normal_at(&c, t) + }; + let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; + pts.push(c.eval(t) + nrm * (amp * sign)); + k += 1; + } + } + if !sp.closed { + if segs > 0 { + let c = sp.segment(segs - 1); + let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; + pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign)); + } else { + pts.extend(sp.anchors.iter().map(|a| a.p)); + } + } + subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) }); + } + PathData::new(subs) +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ffe06101..cd84da59 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1,2 +1,326 @@ -//! DrawCraft live effects evaluated to geometry +//! DrawCraft live effects (Illustrator's Effect menu). +//! +//! Effects live in appearance stacks as [`Effect`] `{id, params, visible}` records. This crate +//! interprets them: +//! +//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, +//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. +//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by +//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond +//! the geometry. +//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the +//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. +//! +//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash +//! noise (`seed` parameter, default 0). #![forbid(unsafe_code)] + +mod distort; +mod raster; +mod stylize; +mod util; +mod warp; + +#[cfg(test)] +mod tests; + +use drawcraft_doc::Effect; +use drawcraft_geom::{BezPath, PathData, Rect}; +use serde_json::{Map, Value, json}; + +pub use raster::{RasterFx, outset, raster_effects}; +pub use warp::{WarpStyle, warp_point}; + +/// Catalogue entry for one effect. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectInfo { + /// Stable id, e.g. `distort.roughen`. + pub id: &'static str, + /// Menu label (with the ellipsis when the effect has a dialog). + pub label: &'static str, + /// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`. + pub menu: &'static [&'static str], + /// Human/agent-readable parameter documentation. + pub params: &'static str, + /// Dialog defaults. + pub defaults: Value, + /// Raster (painted) rather than geometry effect. + pub raster: bool, +} + +const WARP_DOC: &str = + "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const DT: &[&str] = &["Effect", "Distort & Transform"]; +const PATH: &[&str] = &["Effect", "Path"]; +const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; +const STYLIZE: &[&str] = &["Effect", "Stylize"]; +const WARP: &[&str] = &["Effect", "Warp"]; +const BLUR: &[&str] = &["Effect", "Blur"]; + +/// The warp styles in Illustrator's Style menu order: (id suffix, label). +pub const WARP_STYLES: [(&str, &str); 15] = [ + ("arc", "Arc…"), + ("arcLower", "Arc Lower…"), + ("arcUpper", "Arc Upper…"), + ("arch", "Arch…"), + ("bulge", "Bulge…"), + ("shellLower", "Shell Lower…"), + ("shellUpper", "Shell Upper…"), + ("flag", "Flag…"), + ("wave", "Wave…"), + ("fish", "Fish…"), + ("rise", "Rise…"), + ("fisheye", "Fisheye…"), + ("inflate", "Inflate…"), + ("squeeze", "Squeeze…"), + ("twist", "Twist…"), +]; + +/// Every supported effect with its dialog defaults. +pub fn effect_catalog() -> Vec { + let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false }; + let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true }; + let mut v = vec![ + g( + "convertToShape.rectangle", + "Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "convertToShape.roundedRectangle", + "Rounded Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}), + ), + g( + "convertToShape.ellipse", + "Ellipse…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "distort.freeDistort", + "Free Distort…", + DT, + "{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}", + json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), + ), + g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), + g( + "distort.roughen", + "Roughen…", + DT, + "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), + ), + g( + "distort.transform", + "Transform…", + DT, + "{scaleH: % (100), scaleV: % (100), moveH: pt (0), moveV: pt (0), rotate: deg (0), copies: int (0), reflectX: bool, reflectY: bool}", + json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false}), + ), + g( + "distort.tweak", + "Tweak…", + DT, + "{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}", + json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}), + ), + g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})), + g( + "distort.zigZag", + "Zig Zag…", + DT, + "{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}", + json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}), + ), + g( + "path.offsetPath", + "Offset Path…", + PATH, + "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", + json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), + ), + g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})), + g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), + g( + "stylize.scribble", + "Scribble…", + STYLIZE, + "{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", + json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}), + ), + r( + "stylize.dropShadow", + "Drop Shadow…", + STYLIZE, + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}", + json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}), + ), + r( + "stylize.innerGlow", + "Inner Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}), + ), + r( + "stylize.outerGlow", + "Outer Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}), + ), + r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), + r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), + ]; + for (suffix, label) in WARP_STYLES { + let id: &'static str = match suffix { + "arc" => "warp.arc", + "arcLower" => "warp.arcLower", + "arcUpper" => "warp.arcUpper", + "arch" => "warp.arch", + "bulge" => "warp.bulge", + "shellLower" => "warp.shellLower", + "shellUpper" => "warp.shellUpper", + "flag" => "warp.flag", + "wave" => "warp.wave", + "fish" => "warp.fish", + "rise" => "warp.rise", + "fisheye" => "warp.fisheye", + "inflate" => "warp.inflate", + "squeeze" => "warp.squeeze", + _ => "warp.twist", + }; + v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + v +} + +/// Catalogue entry for `id`. +pub fn effect_info(id: &str) -> Option { + effect_catalog().into_iter().find(|e| e.id == id) +} + +/// Dialog defaults for `id` (`None` for unknown effects). +pub fn default_params(id: &str) -> Option { + effect_info(id).map(|e| e.defaults) +} + +/// `params` layered over the defaults of `id` (unknown keys are kept). +pub fn merged_params(id: &str, params: &Value) -> Value { + let mut out = match default_params(id) { + Some(Value::Object(m)) => m, + _ => Map::new(), + }; + if let Value::Object(p) = params { + for (k, v) in p { + out.insert(k.clone(), v.clone()); + } + } + Value::Object(out) +} + +/// A new effect record with default parameters (overridden by `params`). +pub fn new_effect(id: &str, params: &Value) -> Option { + effect_info(id)?; + Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) +} + +/// Is `id` a raster (painted) effect? +pub fn is_raster(id: &str) -> bool { + matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") +} + +/// Does `id` change geometry? +pub fn is_geometry(id: &str) -> bool { + !is_raster(id) && effect_info(id).is_some() +} + +/// Any visible geometry effect in the list? +pub fn has_geometry(effects: &[Effect]) -> bool { + effects.iter().any(|e| e.visible && is_geometry(&e.id)) +} + +/// Context the geometry effects may need from the object's appearance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GeomContext { + /// Stroke weight used by `path.outlineStroke` when the effect has no `width`. + pub stroke_width: f64, +} + +impl Default for GeomContext { + fn default() -> Self { + Self { stroke_width: 1.0 } + } +} + +/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the +/// reference box of the first effect (usually the object's geometric bounds); each later effect +/// uses the bounds of its input. Raster and unknown effects are skipped. +pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { + apply_geometry_with(effects, path, bounds, &GeomContext::default()) +} + +/// [`apply_geometry`] with an explicit [`GeomContext`]. +pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { + let mut cur = path.clone(); + let mut b = bounds; + let mut first = true; + for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) { + if !first { + b = cur.bounds().unwrap_or(b); + } + first = false; + if cur.is_empty() { + break; + } + let p = merged_params(&e.id, &e.params); + cur = apply_one(&e.id, &p, &cur, b, ctx); + } + cur +} + +/// Convenience wrapper on kurbo paths. +pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath { + apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() +} + +fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { + use util::*; + match id { + "distort.freeDistort" => distort::free_distort(path, b, p), + "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), + "distort.roughen" => distort::roughen(path, b, p), + "distort.transform" => distort::transform(path, b, p), + "distort.tweak" => distort::tweak(path, b, p), + "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)), + "distort.zigZag" => distort::zig_zag(path, b, p), + "path.offsetPath" => { + let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); + if off.abs() < 1e-9 { + return path.clone(); + } + drawcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) + } + "path.outlineStroke" => { + let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width); + if w <= 0.0 { + return path.clone(); + } + drawcraft_pathops::outline_stroke(path, w, drawcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0)) + } + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), + "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), + "stylize.scribble" => stylize::scribble(path, b, p), + _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { + Some(style) => warp::warp(path, b, style, p), + None => path.clone(), + }, + } +} diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs new file mode 100644 index 00000000..9bb16d5a --- /dev/null +++ b/crates/effects/src/raster.rs @@ -0,0 +1,125 @@ +//! Raster (painted) effects: descriptions for the renderer. + +use drawcraft_doc::Effect; +use drawcraft_doc::color::{BlendMode, Color}; +use serde_json::Value; + +use crate::merged_params; +use crate::util::*; + +/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses +/// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur). +#[derive(Clone, Debug, PartialEq)] +pub enum RasterFx { + DropShadow { + mode: BlendMode, + opacity: f32, + dx: f64, + dy: f64, + blur: f64, + color: Color, + }, + OuterGlow { + mode: BlendMode, + opacity: f32, + blur: f64, + color: Color, + }, + InnerGlow { + mode: BlendMode, + opacity: f32, + blur: f64, + color: Color, + center: bool, + }, + /// Soften the object's edges inward over `radius`. + Feather { + radius: f64, + }, + /// Blur the whole object (σ = radius / 2). + GaussianBlur { + radius: f64, + }, +} + +impl RasterFx { + /// Painted below the object (shadows, outer glows)? + pub fn is_below(&self) -> bool { + matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. }) + } + /// How far this effect reaches beyond the geometry. + pub fn outset(&self) -> f64 { + match self { + RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur, + RasterFx::OuterGlow { blur, .. } => 1.5 * blur, + RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0, + RasterFx::GaussianBlur { radius } => 1.5 * radius, + } + } +} + +/// Parse a blend mode name (`"multiply"`, `"Screen"`, `"color-burn"`…). +pub fn blend_mode(s: &str) -> BlendMode { + let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal) +} + +fn color(p: &Value, default: Color) -> Color { + match p.get("color") { + Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), + Some(Value::Array(a)) if a.len() >= 3 => { + let f = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32; + Color::rgb(f(0), f(1), f(2)) + } + Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).unwrap_or(default), + _ => default, + } +} + +fn opacity(p: &Value) -> f32 { + (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 +} + +/// The visible raster effects of `effects`, in stack order. +pub fn raster_effects(effects: &[Effect]) -> Vec { + effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| { + let p = merged_params(&e.id, &e.params); + let blur = num(&p, "blur", 5.0).clamp(0.0, 1000.0); + Some(match e.id.as_str() { + "stylize.dropShadow" => RasterFx::DropShadow { + mode: blend_mode(text(&p, "mode", "multiply")), + opacity: opacity(&p), + dx: num(&p, "x", 7.0).clamp(-1e4, 1e4), + dy: num(&p, "y", 7.0).clamp(-1e4, 1e4), + blur, + color: color(&p, Color::BLACK), + }, + "stylize.outerGlow" => RasterFx::OuterGlow { + mode: blend_mode(text(&p, "mode", "screen")), + opacity: opacity(&p), + blur, + color: color(&p, Color::rgb(1.0, 1.0, 0.0)), + }, + "stylize.innerGlow" => RasterFx::InnerGlow { + mode: blend_mode(text(&p, "mode", "screen")), + opacity: opacity(&p), + blur, + color: color(&p, Color::WHITE), + center: text(&p, "source", "edge").eq_ignore_ascii_case("center"), + }, + "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, + "blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, + _ => return None, + }) + }) + .collect() +} + +/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in +/// document units. Geometry effects are accounted for by evaluating them. +pub fn outset(effects: &[Effect]) -> f64 { + raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max) +} diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs new file mode 100644 index 00000000..b6f2f6bd --- /dev/null +++ b/crates/effects/src/stylize.rs @@ -0,0 +1,161 @@ +//! Convert to Shape, Round Corners and Scribble. + +use drawcraft_geom::kurbo; +use drawcraft_geom::shapes::{self, KAPPA}; +use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; +use serde_json::Value; + +use crate::util::*; + +/// Convert to Shape (rectangle / rounded rectangle / ellipse) around the bounds. +pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData { + let r = if flag(p, "relative", true) { + let (ew, eh) = (num(p, "extraW", 18.0), num(p, "extraH", 18.0)); + let r = b.inflate(ew / 2.0, eh / 2.0); + Rect::new(r.x0.min(b.center().x), r.y0.min(b.center().y), r.x1.max(b.center().x), r.y1.max(b.center().y)) + } else { + let (w, h) = (num(p, "width", 100.0).abs(), num(p, "height", 100.0).abs()); + Rect::from_center_size(b.center(), (w, h)) + }; + match id { + "convertToShape.ellipse" => shapes::ellipse(r), + "convertToShape.roundedRectangle" => shapes::rounded_rectangle(r, num(p, "radius", 9.0)), + _ => shapes::rectangle(r), + } +} + +/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. +pub fn round_corners(path: &PathData, radius: f64) -> PathData { + if radius <= 0.0 { + return path.clone(); + } + let mut subs = vec![]; + for sp in &path.subpaths { + let n = sp.anchors.len(); + if n < 3 && (n != 2 || sp.closed) { + subs.push(sp.clone()); + continue; + } + let mut out: Vec = vec![]; + for i in 0..n { + let a = sp.anchors[i]; + let interior = sp.closed || (i > 0 && i + 1 < n); + if !interior || a.has_in() || a.has_out() { + out.push(a); + continue; + } + let prev = &sp.anchors[(i + n - 1) % n]; + let next = &sp.anchors[(i + 1) % n]; + // Tangent directions toward the neighbours (their handles if the segments curve). + let tp = if prev.has_out() { prev.h_out } else { prev.p }; + let tn = if next.has_in() { next.h_in } else { next.p }; + let (vp, vn) = (tp - a.p, tn - a.p); + let (lp, ln) = (vp.hypot(), vn.hypot()); + if lp < 1e-9 || ln < 1e-9 { + out.push(a); + continue; + } + let (up, un) = (vp / lp, vn / ln); + // Nearly straight: nothing to round. + if up.dot(un) < -0.9999 { + out.push(a); + continue; + } + let d = radius.min(prev.p.distance(a.p) / 2.0).min(next.p.distance(a.p) / 2.0); + let pa = a.p + up * d; + let pb = a.p + un * d; + let k = d * KAPPA; + out.push(Anchor::with_handles(pa, pa, pa - up * k)); + out.push(Anchor::with_handles(pb, pb - un * k, pb)); + } + subs.push(SubPath::new(out, sp.closed)); + } + PathData::new(subs) +} + +fn flatten(path: &PathData) -> Vec> { + let mut polys: Vec> = vec![]; + kurbo::flatten(path.to_bezpath(), 0.25, |el| match el { + kurbo::PathEl::MoveTo(p) => polys.push(vec![p]), + kurbo::PathEl::LineTo(p) => { + if let Some(l) = polys.last_mut() { + l.push(p) + } + } + kurbo::PathEl::ClosePath => { + if let Some(l) = polys.last_mut() + && let Some(&f) = l.first() + { + l.push(f) + } + } + _ => {} + }); + polys +} + +/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined +/// to `strokeWidth` so the fill paints it. +pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { + let angle = num(p, "angle", 30.0).to_radians(); + let overlap = num(p, "overlap", 0.0); + let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0); + let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0; + let spacing = num(p, "spacing", 5.0).max(0.1); + let variation = num(p, "variation", 0.5).clamp(0.0, 100.0); + let seed = seed(p); + let c = b.center(); + let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2()); + let from_local = to_local.inverse(); + let polys: Vec> = flatten(path).into_iter().map(|pl| pl.into_iter().map(|q| from_local * q).collect()).collect(); + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for q in polys.iter().flatten() { + y0 = y0.min(q.y); + y1 = y1.max(q.y); + } + if !y0.is_finite() || y1 - y0 < 1e-9 { + return PathData::default(); + } + let step = spacing.max((y1 - y0) / 2000.0); + let mut pts: Vec = vec![]; + let mut y = y0 + step / 2.0; + let mut row = 0u64; + while y < y1 { + let mut xs: Vec = vec![]; + for pl in &polys { + for w in pl.windows(2) { + let (a, bq) = (w[0], w[1]); + if (a.y <= y && bq.y > y) || (bq.y <= y && a.y > y) { + xs.push(a.x + (y - a.y) / (bq.y - a.y) * (bq.x - a.x)); + } + } + } + xs.sort_by(f64::total_cmp); + let mut spans: Vec<(f64, f64)> = xs.chunks_exact(2).map(|s| (s[0] - overlap, s[1] + overlap)).collect(); + if row % 2 == 1 { + spans.reverse(); + } + for (a, bx) in spans { + let jitter = |k: u64| variation * noise(seed, row, k, 3); + let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3))); + if row.is_multiple_of(2) { + pts.push(l); + pts.push(r); + } else { + pts.push(r); + pts.push(l); + } + } + row += 1; + y += step; + } + if pts.len() < 2 { + return PathData::default(); + } + let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) }; + let mut center = PathData::single(line); + center.transform(to_local); + let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round); + let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05); + PathData::from_bezpath(&outline) +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs new file mode 100644 index 00000000..1dbb2372 --- /dev/null +++ b/crates/effects/src/tests.rs @@ -0,0 +1,325 @@ +use drawcraft_doc::Effect; +use drawcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes}; +use serde_json::{Value, json}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn square() -> PathData { + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)) +} + +fn run(id: &str, params: Value, p: &PathData) -> PathData { + apply_geometry(&[fx(id, params)], p, p.bounds().unwrap()) +} + +fn close(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +fn all_finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_out.y.is_finite()) +} + +/// Every geometry effect with non-trivial parameters. +fn geometry_cases() -> Vec<(&'static str, Value)> { + let mut v = vec![ + ("distort.freeDistort", json!({"corners": [[0.1, 0.0], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})), + ("distort.puckerBloat", json!({"amount": 40})), + ("distort.puckerBloat", json!({"amount": -40})), + ("distort.roughen", json!({})), + ("distort.roughen", json!({"points": "corner", "size": 3, "relative": false})), + ("distort.transform", json!({"scaleH": 50, "rotate": 30, "moveH": 10})), + ("distort.tweak", json!({})), + ("distort.twist", json!({"angle": 90})), + ("distort.zigZag", json!({})), + ("distort.zigZag", json!({"points": "corner", "ridges": 3})), + ("path.offsetPath", json!({"offset": 5})), + ("path.outlineStroke", json!({"width": 4})), + ("convertToShape.rectangle", json!({})), + ("convertToShape.roundedRectangle", json!({})), + ("convertToShape.ellipse", json!({})), + ("stylize.roundCorners", json!({"radius": 10})), + ]; + for (s, _) in WARP_STYLES { + let id: &'static str = Box::leak(format!("warp.{s}").into_boxed_str()); + v.push((id, json!({"bend": 50}))); + } + v +} + +#[test] +fn catalog_complete_and_unique() { + let cat = effect_catalog(); + let mut ids: Vec<_> = cat.iter().map(|e| e.id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), cat.len()); + assert!(cat.len() >= 34); + for e in &cat { + assert!(e.defaults.is_object(), "{}", e.id); + assert!(!e.params.is_empty()); + assert_eq!(e.menu[0], "Effect"); + assert_eq!(is_raster(e.id), e.raster); + } +} + +#[test] +fn defaults_match_dialogs() { + let d = default_params("stylize.dropShadow").unwrap(); + assert_eq!(d["opacity"], json!(75.0)); + assert_eq!(d["x"], json!(7.0)); + assert_eq!(d["mode"], json!("multiply")); + let r = default_params("distort.roughen").unwrap(); + assert_eq!(r["size"], json!(5.0)); + assert_eq!(r["detail"], json!(10.0)); + assert!(default_params("nope").is_none()); +} + +#[test] +fn merged_params_override_defaults() { + let e = new_effect("distort.twist", &json!({"angle": 45})).unwrap(); + assert_eq!(e.params["angle"], json!(45)); + let e = new_effect("path.offsetPath", &json!({})).unwrap(); + assert_eq!(e.params["joins"], json!("miter")); + assert!(new_effect("bogus.effect", &json!({})).is_none()); +} + +#[test] +fn every_geometry_effect_changes_geometry_and_keeps_closedness() { + let sq = square(); + for (id, params) in geometry_cases() { + let out = run(id, params.clone(), &sq); + assert!(!out.is_empty(), "{id} produced nothing"); + assert!(out.is_closed(), "{id} opened the path"); + assert!(all_finite(&out), "{id} produced NaN"); + assert_ne!(out.to_bezpath(), sq.to_bezpath(), "{id} did nothing"); + } +} + +#[test] +fn geometry_effects_are_deterministic() { + let sq = square(); + for (id, params) in geometry_cases() { + let a = run(id, params.clone(), &sq); + let b = run(id, params, &sq); + assert_eq!(a, b, "{id} is not deterministic"); + } +} + +#[test] +fn open_paths_stay_open() { + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false)); + for id in ["distort.roughen", "distort.zigZag", "distort.twist", "distort.tweak", "warp.arc", "warp.flag", "stylize.roundCorners"] { + let out = run(id, json!({}), &line); + assert!(!out.is_closed(), "{id}"); + assert_eq!(out.subpaths.len(), 1); + } +} + +#[test] +fn warp_keeps_bounds_sane() { + let sq = square(); + let d = 100.0f64.hypot(100.0); + for (s, _) in WARP_STYLES { + for bend in [-100.0, -50.0, 50.0, 100.0] { + for orient in ["horizontal", "vertical"] { + let out = run(&format!("warp.{s}"), json!({"bend": bend, "horizontal": 30, "vertical": -30, "orientation": orient}), &sq); + let b = out.bounds().unwrap(); + assert!(all_finite(&out), "warp.{s}"); + assert!(b.width() > 1.0 && b.height() > 1.0, "warp.{s} collapsed: {b:?}"); + assert!(b.width() < 3.0 * d && b.height() < 3.0 * d, "warp.{s} exploded: {b:?}"); + assert!(b.center().distance(Point::new(50.0, 50.0)) < d, "warp.{s} drifted"); + } + } + } +} + +#[test] +fn warp_zero_bend_is_identity_ish() { + let sq = square(); + for (s, _) in WARP_STYLES { + let out = run(&format!("warp.{s}"), json!({"bend": 0}), &sq); + let b = out.bounds().unwrap(); + assert!(close(b.x0, 0.0, 1e-6) && close(b.x1, 100.0, 1e-6) && close(b.y1, 100.0, 1e-6), "warp.{s}: {b:?}"); + } +} + +#[test] +fn warp_arc_bends_the_top_edge() { + let out = run("warp.arc", json!({"bend": 50}), &square()); + // The ends droop below the original bottom while the middle keeps the top. + let b = out.bounds().unwrap(); + assert!(b.y1 > 101.0 && close(b.y0, 0.0, 0.5), "{b:?}"); + let out = run("warp.arc", json!({"bend": 50, "orientation": "vertical"}), &square()); + let b = out.bounds().unwrap(); + assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}"); +} + +#[test] +fn transform_copies_and_move() { + let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square()); + assert_eq!(out.subpaths.len(), 4); + let b = out.bounds().unwrap(); + assert!(close(b.x1, 160.0, 1e-9)); + let r = run("distort.transform", json!({"scaleH": 50, "scaleV": 50}), &square()).bounds().unwrap(); + assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 50.0, 1e-9)); + let f = run("distort.transform", json!({"reflectX": true, "moveV": 5}), &square()).bounds().unwrap(); + assert!(close(f.y0, 5.0, 1e-9)); +} + +#[test] +fn pucker_bloat_moves_anchors_in_opposite_directions() { + let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square()); + let a = bloat.subpaths[0].anchors[0].p; + assert!(a.x > 0.0 && a.y > 0.0, "bloat pulls corners in: {a:?}"); + let pucker = run("distort.puckerBloat", json!({"amount": -50}), &square()); + let a = pucker.subpaths[0].anchors[0].p; + assert!(a.x < 0.0 && a.y < 0.0, "pucker pushes corners out: {a:?}"); + assert_eq!(run("distort.puckerBloat", json!({"amount": 0}), &square()), square()); +} + +#[test] +fn roughen_seed_changes_result_and_detail_adds_points() { + let a = run("distort.roughen", json!({"seed": 1}), &square()); + let b = run("distort.roughen", json!({"seed": 2}), &square()); + assert_ne!(a, b); + let lo = run("distort.roughen", json!({"detail": 1}), &square()); + let hi = run("distort.roughen", json!({"detail": 50}), &square()); + assert!(hi.anchor_count() > lo.anchor_count()); + // Displacement is bounded by size (5% of 100 = 5 pt). + let bb = a.bounds().unwrap(); + assert!(bb.x0 > -8.0 && bb.x1 < 108.0); +} + +#[test] +fn zigzag_ridge_count_and_amplitude() { + let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); + assert_eq!(out.anchor_count(), 4 * 5); + let b = out.bounds().unwrap(); + assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}"); +} + +#[test] +fn offset_path_grows_and_shrinks() { + let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap(); + assert!(close(grow.width(), 120.0, 0.1), "{grow:?}"); + let shrink = run("path.offsetPath", json!({"offset": -10}), &square()).bounds().unwrap(); + assert!(close(shrink.width(), 80.0, 0.1), "{shrink:?}"); +} + +#[test] +fn outline_stroke_uses_context_width() { + let ctx = GeomContext { stroke_width: 10.0 }; + let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx); + let b = out.bounds().unwrap(); + assert!(close(b.width(), 110.0, 0.1), "{b:?}"); +} + +#[test] +fn convert_to_shape_sizes() { + let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap(); + assert!(close(r.width(), 120.0, 1e-9) && close(r.height(), 110.0, 1e-9)); + let e = run("convertToShape.ellipse", json!({"relative": false, "width": 40, "height": 20}), &square()); + let b = e.bounds().unwrap(); + assert!(close(b.width(), 40.0, 1e-6) && close(b.center().x, 50.0, 1e-9)); + assert_eq!(e.anchor_count(), 4); +} + +#[test] +fn round_corners_replaces_corners() { + let out = run("stylize.roundCorners", json!({"radius": 10}), &square()); + assert_eq!(out.anchor_count(), 8); + // Area shrinks by 4 × (r² − πr²/4) ≈ 85.8 for r = 10. + let area = out.to_bezpath().area().abs(); + assert!(close(area, 10000.0 - 85.84, 2.0), "{area}"); + // An ellipse has no sharp corners → unchanged. + let el = shapes::ellipse(Rect::new(0.0, 0.0, 50.0, 30.0)); + assert_eq!(run("stylize.roundCorners", json!({}), &el), el); +} + +#[test] +fn twist_fixes_the_outer_circle_and_rotates_inside() { + let out = run("distort.twist", json!({"angle": 0}), &square()); + assert_eq!(out, square()); + let small = PathData::single(SubPath::polyline(&[Point::new(45.0, 50.0), Point::new(55.0, 50.0)], false)); + let out = apply_geometry(&[fx("distort.twist", json!({"angle": 90}))], &small, Rect::new(0.0, 0.0, 100.0, 100.0)); + let a = out.subpaths[0].anchors[0].p; + assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}"); +} + +#[test] +fn scribble_produces_filled_outline_inside_bounds() { + let out = run("stylize.scribble", json!({}), &square()); + assert!(!out.is_empty()); + let b = out.bounds().unwrap(); + assert!(b.x0 > -10.0 && b.x1 < 110.0 && b.y0 > -10.0 && b.y1 < 110.0, "{b:?}"); + assert_eq!(out, run("stylize.scribble", json!({}), &square())); +} + +#[test] +fn effects_chain_in_order_and_skip_hidden_and_raster() { + let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))]; + let b = apply_geometry(&chain, &square(), Rect::new(0.0, 0.0, 100.0, 100.0)).bounds().unwrap(); + // Move first (10..110), then scale around the new centre (60): -40..160. + assert!(close(b.x0, -40.0, 1e-9) && close(b.x1, 160.0, 1e-9), "{b:?}"); + let mut hidden = fx("distort.twist", json!({"angle": 90})); + hidden.visible = false; + let raster = fx("stylize.dropShadow", json!({})); + assert_eq!(apply_geometry(&[hidden, raster.clone()], &square(), Rect::new(0.0, 0.0, 100.0, 100.0)), square()); + assert!(!has_geometry(&[raster])); +} + +#[test] +fn raster_effects_parse_and_outset() { + let list = [ + fx("stylize.dropShadow", json!({"x": 10, "y": -4, "blur": 6, "color": "#ff0000", "opacity": 50})), + fx("stylize.outerGlow", json!({})), + fx("stylize.innerGlow", json!({"source": "center"})), + fx("stylize.feather", json!({})), + fx("blur.gaussian", json!({"radius": 2})), + fx("distort.twist", json!({})), + ]; + let r = raster_effects(&list); + assert_eq!(r.len(), 5); + match &r[0] { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { + assert_eq!(*mode, drawcraft_doc::color::BlendMode::Multiply); + assert!(close(*opacity as f64, 0.5, 1e-6)); + assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0)); + assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]); + } + other => panic!("{other:?}"), + } + assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. })); + assert!(close(outset(&list), 10.0 + 9.0, 1e-9)); + assert_eq!(outset(&[]), 0.0); +} + +#[test] +fn blend_mode_names() { + use drawcraft_doc::color::BlendMode; + assert_eq!(raster::blend_mode("Color-Burn"), BlendMode::ColorBurn); + assert_eq!(raster::blend_mode("screen"), BlendMode::Screen); + assert_eq!(raster::blend_mode("???"), BlendMode::Normal); +} + +#[test] +fn free_distort_identity_and_corner_move() { + let id = run("distort.freeDistort", json!({}), &square()).bounds().unwrap(); + assert!(close(id.x0, 0.0, 1e-9) && close(id.x1, 100.0, 1e-9)); + let out = run("distort.freeDistort", json!({"corners": [[0, 0], [1.5, 0], [1, 1], [0, 1]]}), &square()); + let b = out.bounds().unwrap(); + assert!(close(b.x1, 150.0, 1e-6), "{b:?}"); +} + +#[test] +fn bad_params_fall_back_to_defaults() { + let out = run("distort.roughen", json!({"size": "abc", "detail": null, "points": 7}), &square()); + assert_eq!(out, run("distort.roughen", json!({}), &square())); + let out = run("path.offsetPath", json!({"offset": "12 pt"}), &square()); + assert!(close(out.bounds().unwrap().width(), 124.0, 0.1)); +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs new file mode 100644 index 00000000..d2281d19 --- /dev/null +++ b/crates/effects/src/util.rs @@ -0,0 +1,147 @@ +//! Parameter parsing, deterministic noise and path-mapping helpers. + +use drawcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use serde_json::Value; + +/// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default. +pub fn num(p: &Value, key: &str, default: f64) -> f64 { + match p.get(key) { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => { + let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); + t.parse().ok() + } + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + } + .filter(|v| v.is_finite()) + .unwrap_or(default) +} + +pub fn flag(p: &Value, key: &str, default: bool) -> bool { + match p.get(key) { + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().is_some_and(|v| v != 0.0), + _ => default, + } +} + +pub fn text<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { + p.get(key).and_then(Value::as_str).unwrap_or(default) +} + +pub fn join(p: &Value, key: &str) -> drawcraft_pathops::Join { + match text(p, key, "miter").to_ascii_lowercase().as_str() { + "round" => drawcraft_pathops::Join::Round, + "bevel" => drawcraft_pathops::Join::Bevel, + _ => drawcraft_pathops::Join::Miter, + } +} + +/// "smooth" (default) vs "corner" points. +pub fn smooth_points(p: &Value) -> bool { + !text(p, "points", "smooth").eq_ignore_ascii_case("corner") +} + +/// Deterministic hash noise in [-1, 1] for (seed, a, b, c). +pub fn noise(seed: u64, a: u64, b: u64, c: u64) -> f64 { + let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ a.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ b.wrapping_mul(0x94D0_49BB_1331_11EB) ^ c; + // splitmix64 finaliser + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^= x >> 31; + (x >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 +} + +pub fn seed(p: &Value) -> u64 { + num(p, "seed", 0.0).abs() as u64 +} + +/// Mean side of a box (the reference for "relative" sizes). +pub fn mean_size(b: Rect) -> f64 { + (b.width().abs() + b.height().abs()) / 2.0 +} + +/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. +pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { + let c = sp.segment(i); + if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } +} + +fn poly_len(c: &CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces +/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which +/// approximates the image curve to O(h²). Closedness and subpath structure are preserved. +pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { + let max_piece = max_piece.max(1e-3); + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + let segs = sp.segment_count(); + let mut res: Vec = Vec::new(); + let mut pending_in: Option = None; + for i in 0..n { + let a = &sp.anchors[i]; + let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in)); + res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); + if i < segs { + let c = seg_cubic(sp, i); + let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64); + for j in 0..k { + let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); + res.last_mut().unwrap().h_out = f(sub.p1); + if j + 1 < k { + res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() }); + } else { + pending_in = Some(f(sub.p2)); + } + } + } + } + if let Some(h) = pending_in + && sp.closed + { + res[0].h_in = h; + } + let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect(); + subs.push(SubPath::new(anchors, sp.closed)); + } + PathData::new(subs) +} + +/// Smooth subpath through `pts` (Catmull-Rom tangents). +pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath { + let n = pts.len(); + if n < 3 { + return SubPath::polyline(pts, closed); + } + let anchors = (0..n) + .map(|i| { + let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] }; + let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] }; + let t: Vec2 = (next - prev) * (tension / 6.0); + Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t) + }) + .collect(); + SubPath::new(anchors, closed) +} + +/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`. +pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 { + let eps = 1e-4; + let a = c.eval((t - eps).max(0.0)); + let b = c.eval((t + eps).min(1.0)); + let mut d = b - a; + if d.hypot() < 1e-12 { + d = c.p3 - c.p0; + } + let len = d.hypot(); + if len < 1e-12 { Vec2::ZERO } else { Vec2::new(d.y / len, -d.x / len) } +} diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs new file mode 100644 index 00000000..6280aa5e --- /dev/null +++ b/crates/effects/src/warp.rs @@ -0,0 +1,133 @@ +//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion. +//! +//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is +//! split into short pieces whose control points are mapped (see `map_nonlinear`). + +use std::f64::consts::PI; + +use drawcraft_geom::{PathData, Point, Rect}; +use serde_json::Value; + +use crate::util::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WarpStyle { + Arc, + ArcLower, + ArcUpper, + Arch, + Bulge, + ShellLower, + ShellUpper, + Flag, + Wave, + Fish, + Rise, + Fisheye, + Inflate, + Squeeze, + Twist, +} + +impl WarpStyle { + /// From the id suffix (`arc`, `arcLower`, …). + pub fn from_id(s: &str) -> Option { + use WarpStyle::*; + Some(match s { + "arc" => Arc, + "arcLower" => ArcLower, + "arcUpper" => ArcUpper, + "arch" => Arch, + "bulge" => Bulge, + "shellLower" => ShellLower, + "shellUpper" => ShellUpper, + "flag" => Flag, + "wave" => Wave, + "fish" => Fish, + "rise" => Rise, + "fisheye" => Fisheye, + "inflate" => Inflate, + "squeeze" => Squeeze, + "twist" => Twist, + _ => return None, + }) + } +} + +/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1). +pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) { + use WarpStyle::*; + let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom + let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides + let (mut x2, mut y2) = match style { + Arc => { + if b.abs() < 1e-6 { + (x, y) + } else { + // Bend the centre line into a circular arc of equal length (sweep = |b|·180°). + let sweep = b.abs() * PI; + let r0 = 2.0 / sweep; + let yy = if b > 0.0 { y } else { -y }; + let a = x / r0; + let r = r0 - yy; + let (s, c) = a.sin_cos(); + let (nx, ny) = (r * s, r0 - r * c); + (nx, if b > 0.0 { ny } else { -ny }) + } + } + ArcLower => (x, y + b * par * t), + ArcUpper => (x, y - b * par * (1.0 - t)), + Arch => (x, y - b * par), + Bulge => (x, y + b * par * y), + ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t), + ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)), + Flag => (x, y + 0.5 * b * (PI * x).sin()), + Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)), + Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())), + Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b), + Fisheye => { + let r2 = x * x + y * y; + let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 }; + (x * s, y * s) + } + Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)), + Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)), + Twist => { + let r = (x * x + y * y).sqrt(); + let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0); + let (s, c) = a.sin_cos(); + (x * c - y * s, x * s + y * c) + } + }; + // Perspective-like distortion: horizontal narrows one side, vertical one end. + if dh != 0.0 { + y2 *= (1.0 + dh * x2 * 0.5).max(0.0); + } + if dv != 0.0 { + x2 *= (1.0 + dv * y2 * 0.5).max(0.0); + } + (x2, y2) +} + +/// Apply a warp to `path` using box `b`. +pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData { + let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; + let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; + let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; + let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); + let c = b.center(); + let hw = (b.width() / 2.0).max(1e-9); + let hh = (b.height() / 2.0).max(1e-9); + let f = |q: Point| { + let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); + let (x2, y2) = if vertical { + let (a, b2) = warp_point(style, bend, dh, dv, y, x); + (b2, a) + } else { + warp_point(style, bend, dh, dv, x, y) + }; + Point::new(c.x + x2 * hw, c.y + y2 * hh) + }; + let piece = b.width().hypot(b.height()) / 24.0; + map_nonlinear(path, piece, f) +} diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 30e619eb..e257bf92 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1,7 +1,1130 @@ -//! Commands backing the drawing tools (pencil, scissors, knife…). +//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser, +//! blob brush, smooth, path eraser, join). -use super::CommandSpec; +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; +use drawcraft_geom::hit::fill_contains; +use drawcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; +use drawcraft_pathops as po; +use drawcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions}; +use kurbo::{ParamCurve, ParamCurveNearest}; +use serde_json::{Value, json}; + +use super::create::add_node; +use super::*; +use crate::EngineError; pub fn specs() -> Vec { - vec![] + vec![ + cmd!( + "path.freehand", + "Pencil", + [], + None, + "{points: [[x,y]…], fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}", + has_doc, + freehand + ), + cmd!( + "path.curvature", + "Curvature", + [], + None, + "{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}", + has_doc, + curvature + ), + cmd!( + "path.removeAnchor", + "Delete Anchor Point", + [], + None, + "{id, subpath, anchor} remove one anchor, re-fitting the curve", + has_doc, + remove_anchor + ), + cmd!( + "path.convertAnchor", + "Convert Anchor Point", + [], + None, + "{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)", + has_doc, + convert_anchor + ), + cmd!( + "path.reshapeSegment", + "Reshape Segment", + [], + None, + "{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)", + has_doc, + reshape_segment + ), + cmd!( + "path.split", + "Split Path", + [], + None, + "{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}", + has_doc, + split + ), + cmd!( + "path.knife", + "Knife", + [], + None, + "{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}", + has_doc, + knife + ), + cmd!( + "path.eraseRegion", + "Eraser", + [], + None, + "{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}", + has_doc, + erase_region + ), + cmd!( + "path.eraseSegments", + "Path Eraser", + [], + None, + "{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}", + has_selection, + erase_segments + ), + cmd!( + "path.smoothRegion", + "Smooth", + [], + None, + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + has_selection, + smooth_region + ), + cmd!( + "path.joinScrub", + "Join", + [], + None, + "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", + has_doc, + join_scrub + ), + cmd!( + "path.blob", + "Blob Brush", + [], + None, + "{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}", + has_doc, + blob + ), + ] +} + +// ---------- helpers ---------- + +/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped. +fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { + let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; + let pts: Vec = a + .iter() + .filter_map(|v| match v { + Value::Array(xy) => Some(Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?)), + Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)), + _ => None, + }) + .filter(|p| p.x.is_finite() && p.y.is_finite()) + .collect(); + if pts.is_empty() { + return Err(bad(cmd, "need at least one point")); + } + Ok(pts) +} + +fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) +} + +fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + Ok(path) + } + _ => Err(EngineError::Other(format!("object {id} is not a path"))), + } +} + +fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> { + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)), + Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))), + None => Err(EngineError::NoNode(id)), + } +} + +fn dist_to_polyline(pts: &[Point], q: Point) -> f64 { + if pts.len() == 1 { + return pts[0].distance(q); + } + pts.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let ab = b - a; + let l2 = ab.hypot2(); + let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) }; + (a + ab * t).distance(q) + }) + .fold(f64::INFINITY, f64::min) +} + +fn poly_bounds(pts: &[Point], pad: f64) -> Rect { + let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)); + r.inflate(pad, pad) +} + +/// The filled area painted by a round brush of diameter `size` along `pts`. +fn brush_region(pts: &[Point], size: f64) -> PathData { + let mut pts = pts.to_vec(); + if pts.len() == 1 { + pts.push(pts[0] + Vec2::new(0.01, 0.0)); + } + po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0) +} + +/// Path leaves the area tools act on: under the selected objects, or every path when nothing is +/// selected. Only editable, non-guide paths whose bounds meet `area`. +fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { + let mut out = vec![]; + let mut push = |n: &Node| { + if let NodeKind::Path { guide: false, path, .. } = &n.kind + && path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0) + && !out.contains(&n.id) + { + out.push(n.id); + } + }; + if sel.is_empty() { + doc.walk(&mut push); + } else { + for id in &sel.objects { + if let Some(n) = doc.node(*id) { + n.walk(&mut |c: &Node| push(c)); + } + } + } + out.retain(|id| doc.is_editable(*id)); + out +} + +/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`. +fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { + let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); + n.id = d.alloc_id(); + if let NodeKind::Path { path, live, .. } = &mut n.kind { + *path = pd; + *live = None; + } + Ok(n) +} + +/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids. +fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec) -> Result> { + let mut it = pieces.into_iter(); + let Some(first) = it.next() else { + d.remove(id)?; + return Ok(vec![]); + }; + let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let mut ids = vec![id]; + for (k, pd) in it.enumerate() { + let n = sibling_with(d, id, pd)?; + ids.push(d.insert(parent, idx + 1 + k, n)?); + } + *path_mut(d, id)? = first; + Ok(ids) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Split a filled path into connected pieces (each outer contour with its holes). +fn components(pd: &PathData) -> Vec { + let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); + if subs.len() <= 1 { + return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; + } + let bps: Vec = subs + .iter() + .map(|s| { + let mut b = BezPath::new(); + s.to_bezpath_into(&mut b); + b + }) + .collect(); + let samples: Vec = subs.iter().map(|s| s.segment(0).eval(0.5)).collect(); + let n = subs.len(); + let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]); + let depth: Vec = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect(); + let areas: Vec = subs.iter().map(|s| s.area().abs()).collect(); + let outers: Vec = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect(); + let mut groups: Vec> = outers.iter().map(|&o| vec![subs[o].clone()]).collect(); + for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) { + let best = outers + .iter() + .enumerate() + .filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h)) + .min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1])); + if let Some((k, _)) = best { + groups[k].push(subs[h].clone()); + } + } + groups.into_iter().map(PathData::new).collect() +} + +/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. +fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option> { + let nseg = sp.segment_count(); + if nseg == 0 { + return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); + } + const K: usize = 24; + let mut runs: Vec> = vec![]; + let mut cur: Vec<(usize, f64, f64)> = vec![]; + let mut any = false; + for i in 0..nseg { + let c = sp.segment(i); + let mut prev_t = 0.0; + let mut prev_rm = remove(c.eval(0.0)); + let mut start = if prev_rm { None } else { Some(0.0) }; + if prev_rm { + any = true; + if !cur.is_empty() { + runs.push(std::mem::take(&mut cur)); + } + } + for k in 1..=K { + let t = k as f64 / K as f64; + let rm = remove(c.eval(t)); + if rm != prev_rm { + let (mut lo, mut hi) = (prev_t, t); + for _ in 0..30 { + let m = 0.5 * (lo + hi); + if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m } + } + let b = 0.5 * (lo + hi); + if prev_rm { + start = Some(b); + } else { + any = true; + if let Some(s) = start.take() { + cur.push((i, s, b)); + } + runs.push(std::mem::take(&mut cur)); + } + } + prev_t = t; + prev_rm = rm; + } + if let Some(s) = start { + cur.push((i, s, 1.0)); + } + } + if !cur.is_empty() { + runs.push(cur); + } + runs.retain(|r| !r.is_empty()); + if !any { + return None; + } + // A closed path's last and first runs meet at anchor 0. + if sp.closed && runs.len() >= 2 { + let first_starts = runs[0][0].0 == 0 && runs[0][0].1 == 0.0; + let last = runs.last().unwrap(); + let last_ends = last[last.len() - 1].0 == nseg - 1 && last[last.len() - 1].2 == 1.0; + if first_starts && last_ends { + let first = runs.remove(0); + runs.last_mut().unwrap().extend(first); + } + } + let out = runs + .iter() + .filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4) + .map(|r| { + let mut anchors: Vec = vec![]; + for &(i, t0, t1) in r { + let c = sp.segment(i).subsegment(t0..t1); + let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) }; + if anchors.is_empty() { + anchors.push(Anchor::corner(c.p0)); + } + anchors.last_mut().unwrap().h_out = h1; + anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner }); + } + for a in &mut anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + SubPath::new(anchors, false) + }) + .filter(|s| s.anchors.len() >= 2) + .collect(); + Some(out) +} + +/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per +/// output object (one object per piece when the path had a single subpath), or None if unchanged. +fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option> { + let mut changed = false; + let mut subs = vec![]; + for sp in &pd.subpaths { + match cut_subpath(sp, remove) { + Some(pieces) => { + changed = true; + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + if !changed { + return None; + } + Some(if pd.subpaths.len() == 1 { + subs.into_iter().map(PathData::single).collect() + } else if subs.is_empty() { + vec![] + } else { + vec![PathData::new(subs)] + }) +} + +fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +fn path_kind(path: PathData) -> NodeKind { + NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false } +} + +// ---------- Pencil / Paintbrush ---------- + +/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay +/// corners) and simplify it to `tol`. +fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { + let min = (tol * 0.3).max(0.05); + let mut kept: Vec = vec![pts[0]]; + for &p in &pts[1..] { + if kept.last().unwrap().distance(p) >= min { + kept.push(p); + } + } + if let (Some(&l), true) = (pts.last(), kept.len() > 1) { + *kept.last_mut().unwrap() = l; + } else if kept.len() == 1 && pts.len() > 1 { + kept.push(pts[pts.len() - 1]); + } + let n = kept.len(); + let marked: Vec<(Point, bool)> = (0..n) + .map(|i| { + let corner = if i == 0 || i + 1 == n { + false + } else { + let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]); + a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100° + }; + (kept[i], corner) + }) + .collect(); + let sp = drawcraft_tools::draw2::catmull_rom(&marked, closed); + if sp.anchors.len() < 3 { + return sp; + } + let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); + fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) +} + +fn freehand(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.freehand"; + let mut pts = points_param(p, "points", C)?; + let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0); + let closed = bool_or(p, "closed", false); + let brush = str_param(p, "style") == Some("brush"); + if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { + let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let at_start = str_param(ext, "end") == Some("start"); + let (pd, _) = path_of(&s.doc()?.doc, id)?; + let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let mut sp = pd.subpaths[si].clone(); + if at_start { + sp.reverse(); + } + let end = sp.anchors.last().unwrap().p; + pts[0] = end; + if pts.len() < 2 { + return Ok(json!({ "id": id.0 })); + } + let first = sp.anchors[0].p; + let close_tol = (tol * 4.0).max(6.0); + let closing = sp.anchors.len() >= 2 && pts.last().unwrap().distance(first) <= close_tol; + if closing { + *pts.last_mut().unwrap() = first; + } + let fit = fit_freehand(&pts, tol, false); + let mut add = fit.anchors; + sp.anchors.last_mut().unwrap().h_out = add[0].h_out; + add.remove(0); + sp.anchors.extend(add); + if closing && sp.anchors.len() > 2 { + let l = sp.anchors.pop().unwrap(); + sp.anchors[0].h_in = l.h_in; + sp.closed = true; + } + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + let path = path_mut(d, id)?; + if at_start && !sp.closed { + sp.reverse(); + } + path.subpaths[si] = sp; + sel.set([id]); + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + let sp = fit_freehand(&pts, tol, closed); + let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None }; + let ap = Appearance::basic(fill, stroke_paint(s), s.paint.stroke_width.max(0.1)); + add_node(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), ap, None) +} + +// ---------- Curvature ---------- + +fn curvature(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.curvature"; + let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; + let pts: Vec<(Point, bool)> = arr + .iter() + .filter_map(|v| { + let pt = match v { + Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), + _ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), + }; + (pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false))) + }) + .collect(); + if pts.is_empty() { + return Err(bad(C, "need at least one point")); + } + let sp = drawcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false)); + if let Some(id) = id_param(p, "id") { + s.edit("Curvature", |d, sel| { + *path_mut(d, id)? = PathData::single(sp); + sel.set([id]); + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { + ap.set_stroke(Paint::solid(Color::BLACK)); + } + add_node(s, "Curvature", path_kind(PathData::single(sp)), ap, None) +} + +// ---------- anchor tools ---------- + +fn remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.removeAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let ai = usize_req(p, "anchor", C)?; + let removed = s.edit("Delete Anchor Point", |d, sel| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let n = sp.anchors.len(); + if ai >= n { + return Err(EngineError::Other("no such anchor".into())); + } + let interior = sp.closed || (ai > 0 && ai + 1 < n); + if interior && n >= 3 { + let (pi, ni) = ((ai + n - 1) % n, (ai + 1) % n); + let (prev, a, next) = (sp.anchors[pi], sp.anchors[ai], sp.anchors[ni]); + // Keep the outer tangents; stretch them to span both removed segments. + let l1 = prev.p.distance(a.p); + let l2 = a.p.distance(next.p); + let chord = prev.p.distance(next.p).max(1e-9); + let first_line = !prev.has_out() && !a.has_in(); + let second_line = !a.has_out() && !next.has_in(); + let (mut hout, mut hin) = (prev.h_out, next.h_in); + if !(first_line && second_line) { + // Curved: stretch the outer handles to span both segments; a straight side aims a + // third of the chord at the removed anchor. + hout = if prev.has_out() { + prev.p + (prev.h_out - prev.p) * ((l1 + l2) / l1.max(1e-9)).min(3.0) + } else { + prev.p + (a.p - prev.p) * (chord / 3.0 / l1.max(1e-9)) + }; + hin = if next.has_in() { + next.p + (next.h_in - next.p) * ((l1 + l2) / l2.max(1e-9)).min(3.0) + } else { + next.p + (a.p - next.p) * (chord / 3.0 / l2.max(1e-9)) + }; + } + sp.anchors[pi].h_out = hout; + sp.anchors[ni].h_in = hin; + } + sp.anchors.remove(ai); + if !sp.closed && !sp.anchors.is_empty() { + let (f, l) = (0, sp.anchors.len() - 1); + let a0 = sp.anchors[f].p; + sp.anchors[f].h_in = a0; + let al = sp.anchors[l].p; + sp.anchors[l].h_out = al; + } + if sp.anchors.len() < 3 { + sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out()); + } + let empty = sp.anchors.len() < 2; + if empty { + path.subpaths.remove(si); + } + if path.is_empty() { + d.remove(id)?; + sel.clear(); + return Ok(true); + } + sel.anchors.remove(&id); + Ok(false) + })?; + Ok(json!({ "removedObject": removed })) +} + +fn convert_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.convertAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let ai = usize_req(p, "anchor", C)?; + let smooth = str_param(p, "to") == Some("smooth"); + let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => Some(Point::new(x, y)), + _ => None, + }; + s.edit("Convert Anchor Point", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let n = sp.anchors.len(); + if ai >= n { + return Err(EngineError::Other("no such anchor".into())); + } + let prev = if ai > 0 || sp.closed { Some(sp.anchors[(ai + n - 1) % n].p) } else { None }; + let next = if ai + 1 < n || sp.closed { Some(sp.anchors[(ai + 1) % n].p) } else { None }; + let a = &mut sp.anchors[ai]; + if !smooth { + a.retract(); + return Ok(()); + } + match target { + Some(t) if t.distance(a.p) > 1e-9 => { + a.h_out = t; + a.h_in = a.p - (t - a.p); + a.kind = AnchorKind::Smooth; + } + Some(_) => a.retract(), + None => { + let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p)); + let dir = nn - pp; + let l = dir.hypot(); + if l > 1e-9 { + let u = dir / l; + a.h_in = a.p - u * (a.p.distance(pp) / 3.0); + a.h_out = a.p + u * (a.p.distance(nn) / 3.0); + a.kind = AnchorKind::Smooth; + } + } + } + Ok(()) + })?; + ok() +} + +fn reshape_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.reshapeSegment"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = usize_req(p, "segment", C)?; + let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95); + let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + if !(dv.x.is_finite() && dv.y.is_finite()) { + return Err(bad(C, "invalid delta")); + } + s.edit("Reshape", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if seg >= sp.segment_count() { + return Err(EngineError::Other("no such segment".into())); + } + let n = sp.anchors.len(); + // Moving both inner control points by v moves B(t) by 3t(1-t)·v. + let v = dv / (3.0 * t * (1.0 - t)); + let (i0, i1) = (seg % n, (seg + 1) % n); + let a = sp.anchors[i0]; + sp.anchors[i0] = Anchor::with_handles(a.p, a.h_in, a.h_out + v); + let b = sp.anchors[i1]; + sp.anchors[i1] = Anchor::with_handles(b.p, b.h_in + v, b.h_out); + Ok(()) + })?; + ok() +} + +// ---------- Scissors ---------- + +fn split(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.split"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize); + let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize); + if anchor.is_none() && seg.is_none() { + return Err(bad(C, "need `anchor` or `segment` + `t`")); + } + let t = f64_or(p, "t", 0.5); + if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) { + return Err(bad(C, "invalid t")); + } + let ids = s.edit("Split Path", |d, sel| { + let path = path_mut(d, id)?; + let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let n = sp.anchors.len(); + let k = match (anchor, seg) { + (Some(a), _) if a < n => a, + (Some(_), _) => return Err(EngineError::Other("no such anchor".into())), + (None, Some(g)) if g < sp.segment_count() => { + if t <= 1e-6 { + g + } else if t >= 1.0 - 1e-6 { + (g + 1) % n + } else { + sp.insert_anchor(g, t) + } + } + _ => return Err(EngineError::Other("no such segment".into())), + }; + if sp.closed { + sp.anchors.rotate_left(k); + let mut last = sp.anchors[0]; + last.h_out = last.p; + sp.anchors[0].h_in = sp.anchors[0].p; + last.kind = AnchorKind::Corner; + sp.anchors[0].kind = AnchorKind::Corner; + sp.anchors.push(last); + sp.closed = false; + path.subpaths[si] = sp; + sel.set([id]); + return Ok(vec![id]); + } + let n = sp.anchors.len(); + if k == 0 || k + 1 >= n { + return Err(EngineError::Other("cannot split an open path at its end point".into())); + } + let mut left = SubPath::new(sp.anchors[..=k].to_vec(), false); + let mut right = SubPath::new(sp.anchors[k..].to_vec(), false); + let lp = left.anchors[k].p; + left.anchors[k].h_out = lp; + left.anchors[k].kind = AnchorKind::Corner; + right.anchors[0].h_in = lp; + right.anchors[0].kind = AnchorKind::Corner; + if path.subpaths.len() == 1 { + path.subpaths[0] = left; + let node = sibling_with(d, id, PathData::single(right))?; + let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let nid = d.insert(parent, idx + 1, node)?; + sel.set([id, nid]); + Ok(vec![id, nid]) + } else { + path.subpaths[si] = left; + path.subpaths.insert(si + 1, right); + sel.set([id]); + Ok(vec![id]) + } + })?; + Ok(ids_json(&ids)) +} + +// ---------- Knife / Eraser ---------- + +/// Width of the slit the knife leaves between pieces. +const KNIFE_GAP: f64 = 0.05; + +fn knife(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.knife"; + let pts = points_param(p, "points", C)?; + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + 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)?; + if !pd.is_closed() { + continue; + } + let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference)); + if pieces.len() >= 2 { + plan.push((id, pieces)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit("Knife", |d, sel| { + let mut all = vec![]; + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, id, pieces)?); + } + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +fn erase_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseRegion"; + let pts = points_param(p, "points", C)?; + let size = f64_or(p, "size", 10.0); + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let r = size / 2.0; + let st = s.doc()?; + let region = brush_region(&pts, size); + 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)?; + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + +fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit(label, |d, sel| { + let mut all = vec![]; + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, id, pieces)?); + } + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +fn erase_segments(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseSegments"; + let pts = points_param(p, "points", C)?; + let w = f64_or(p, "width", 8.0); + if !(w.is_finite() && w > 0.0) { + return Err(bad(C, "width must be positive")); + } + let st = s.doc()?; + let mut plan = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { + let (pd, _) = path_of(&st.doc, id)?; + if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Path Eraser", plan) +} + +// ---------- Smooth ---------- + +/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. +fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { + let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); + let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; + let (f, l) = (anchors[0], anchors[anchors.len() - 1]); + let last = out.len() - 1; + out[0].p = f.p; + out[0].h_in = f.h_in; + out[last].p = l.p; + out[last].h_out = l.h_out; + for a in &mut out { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + out +} + +fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { + let n = sp.anchors.len(); + let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); + if n < 3 || !marked.iter().any(|m| *m) { + return None; + } + if sp.closed { + let Some(k) = marked.iter().position(|m| !m) else { + // Everything is in range: smooth the whole closed path. + let f = + po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + return f.subpaths.into_iter().next(); + }; + // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. + let mut open = sp.clone(); + open.anchors.rotate_left(k); + let mut m = marked.clone(); + m.rotate_left(k); + open.anchors.push(open.anchors[0]); + m.push(false); + open.closed = false; + let mut res = smooth_open(&open.anchors, &m, tol); + let l = res.pop().unwrap(); + res[0].h_in = l.h_in; + return Some(SubPath::new(res, true)); + } + Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false)) +} + +fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec { + let n = anchors.len(); + let mut runs = vec![]; + let mut i = 0; + while i < n { + if marked[i] { + let a = i; + while i + 1 < n && marked[i + 1] { + i += 1; + } + runs.push((a, i)); + } + i += 1; + } + let mut out = anchors.to_vec(); + for (a, b) in runs.into_iter().rev() { + let lo = a.saturating_sub(1); + let hi = (b + 1).min(n - 1); + if hi - lo < 2 { + continue; + } + let new = refit_run(&out[lo..=hi], tol); + out.splice(lo..=hi, new); + } + out +} + +fn smooth_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.smoothRegion"; + let pts = points_param(p, "points", C)?; + let radius = f64_or(p, "radius", 12.0).max(0.0); + let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0); + let st = s.doc()?; + let near = |q: Point| dist_to_polyline(&pts, q) <= radius; + let mut plan = vec![]; + let (mut before, mut after) = (0, 0); + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { + let (pd, _) = path_of(&st.doc, id)?; + let mut changed = false; + let subs: Vec = pd + .subpaths + .iter() + .map(|sp| match smooth_subpath(sp, &near, tol) { + Some(n) if n != *sp && n.anchors.len() >= 2 => { + changed = true; + n + } + _ => sp.clone(), + }) + .collect(); + if changed { + let np = PathData::new(subs); + before += pd.anchor_count(); + after += np.anchor_count(); + plan.push((id, np)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "before": 0, "after": 0 })); + } + let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); + s.edit("Smooth", |d, _| { + for (id, np) in plan { + *path_mut(d, id)? = np; + } + Ok(()) + })?; + let mut v = ids_json(&ids); + v["before"] = json!(before); + v["after"] = json!(after); + Ok(v) +} + +// ---------- Join tool ---------- + +fn join_scrub(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.joinScrub"; + let pts = points_param(p, "points", C)?; + let tol = f64_or(p, "tolerance", 12.0).max(0.0); + let st = s.doc()?; + // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). + let poly = { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for q in &pts[1..] { + bp.line_to(*q); + } + bp + }; + let order = |q: Point| -> f64 { + poly.segments() + .enumerate() + .map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(0.0, |x| x.0) + }; + let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { + let (pd, _) = path_of(&st.doc, id)?; + for (si, sp) in pd.subpaths.iter().enumerate() { + if sp.closed || sp.anchors.len() < 2 { + continue; + } + for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { + if dist_to_polyline(&pts, a) <= tol { + ends.push((order(a), id, si, start, a)); + } + } + } + } + ends.sort_by(|a, b| a.0.total_cmp(&b.0)); + if ends.len() < 2 { + return Ok(json!({ "id": null })); + } + let (_, ida, sa, starta, pa) = ends[0]; + let (_, idb, sb, startb, pb) = ends[1]; + let mid = pa.midpoint(pb); + let merge = pa.distance(pb) <= tol; + s.edit("Join", |d, sel| { + if ida == idb && sa == sb { + let path = path_mut(d, ida)?; + let sp = &mut path.subpaths[sa]; + if merge && sp.anchors.len() > 2 { + let l = sp.anchors.pop().unwrap(); + let d0 = mid - sp.anchors[0].p; + sp.anchors[0].translate(d0); + sp.anchors[0].h_in = l.h_in + (mid - l.p); + } + sp.closed = true; + sel.set([ida]); + return Ok(()); + } + let (pdb, _) = path_of(d, idb)?; + let mut x = path_of(d, ida)?.0.subpaths[sa].clone(); + let mut y = pdb.subpaths[sb].clone(); + if starta { + x.reverse(); + } + if !startb { + y.reverse(); + } + if merge { + let yf = y.anchors.remove(0); + let lp = x.anchors.last().unwrap().p; + let last = x.anchors.last_mut().unwrap(); + last.translate(mid - lp); + last.h_out = yf.h_out + (mid - yf.p); + *last = Anchor::with_handles(last.p, last.h_in, last.h_out); + } + x.anchors.extend(y.anchors); + if ida == idb { + let path = path_mut(d, ida)?; + path.subpaths[sa] = x; + path.subpaths.remove(sb); + } else { + path_mut(d, ida)?.subpaths[sa] = x; + let pbm = path_mut(d, idb)?; + pbm.subpaths.remove(sb); + if pbm.is_empty() { + d.remove(idb)?; + } + } + sel.set([ida]); + Ok(()) + })?; + Ok(json!({ "id": ida.0 })) +} + +// ---------- Blob Brush ---------- + +fn blob(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.blob"; + let pts = points_param(p, "points", C)?; + let size = f64_or(p, "size", 10.0); + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let region = brush_region(&pts, size); + if region.is_empty() { + return Err(EngineError::Other("empty brush stroke".into())); + } + let paint = if !s.paint.stroke.is_none() { + s.paint.stroke.clone() + } else if !s.paint.fill.is_none() { + s.paint.fill.clone() + } else { + Paint::solid(Color::BLACK) + }; + let st = s.doc()?; + let mut merge_ids = vec![]; + if bool_or(p, "merge", true) { + let all = Selection::default(); + for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) { + let Some(n) = st.doc.node(id) else { continue }; + if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { + continue; + } + let (pd, rule) = path_of(&st.doc, id)?; + if pd.is_closed() && po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 { + merge_ids.push((id, pd, rule)); + } + } + } + let ap = Appearance::basic(paint, Paint::None, 1.0); + if merge_ids.is_empty() { + return add_node(s, "Blob Brush", path_kind(region), ap, None); + } + let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; + inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); + let merged = po::unite_all(&inputs); + let keep = merge_ids.last().unwrap().0; + let others: Vec = merge_ids[..merge_ids.len() - 1].iter().map(|m| m.0).collect(); + s.edit("Blob Brush", |d, sel| { + *path_mut(d, keep)? = merged; + for id in others { + d.remove(id)?; + } + sel.set([keep]); + Ok(()) + })?; + Ok(json!({ "id": keep.0 })) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index ada59b92..4f8c5237 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -1,7 +1,318 @@ -//! Effect menu commands (live effects). +//! Effect menu commands (live effects) and Object → Expand Appearance. +//! +//! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render +//! time by `drawcraft-effects` (re-exported by the renderer). -use super::CommandSpec; +use std::sync::Arc; + +use drawcraft_doc::{Node, NodeKind}; +use drawcraft_geom::{FillRule, PathData}; +use drawcraft_render::effects; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; pub fn specs() -> Vec { - vec![] + vec![ + cmd!( + "effect.apply", + "Apply Effect", + [], + None, + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}", + has_selection, + apply + ), + cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list), + cmd!( + "effect.remove", + "Remove Effect", + [], + None, + "{index: int (position in the object's effect list), ids?: [..]} → {ids}", + has_selection, + remove + ), + cmd!( + "effect.setParams", + "Effect Options", + [], + None, + "{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}", + has_selection, + set_params + ), + cmd!( + "effect.expandAppearance", + "Expand Appearance", + ["Object"], + None, + "{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}", + has_selection, + expand_appearance + ), + ] +} + +fn target_roots(s: &Session, p: &Value) -> Result> { + if p.get("ids").is_some() || p.get("id").is_some() { + return targets(s, p); + } + selected_roots(s) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!(ids.iter().map(|i| i.0).collect::>()) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.apply"; + let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; + let params = p.get("params").cloned().unwrap_or(Value::Null); + if !params.is_null() && !params.is_object() { + return Err(bad(C, "`params` must be an object")); + } + let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; + let roots = target_roots(s, p)?; + let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); + let (ids, index) = s.edit(&label, |d, _| { + let mut done = vec![]; + let mut index = 0; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + if n.is_layer() { + continue; + } + n.appearance.effects.push(effect.clone()); + index = n.appearance.effects.len() - 1; + done.push(*id); + } + if done.is_empty() { + return Err(EngineError::Other("Apply Effect: select objects".into())); + } + Ok((done, index)) + })?; + Ok(json!({ "ids": ids_json(&ids), "index": index })) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let catalog: Vec = effects::effect_catalog() + .into_iter() + .map(|e| json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster})) + .collect(); + let mut applied = vec![]; + if s.active().is_some() { + for id in target_roots(s, p)? { + if let Some(n) = s.doc()?.doc.node(id) { + applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)})); + } + } + } + Ok(json!({ "catalog": catalog, "applied": applied })) +} + +fn index_param(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`")) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.remove"; + let index = index_param(p, C)?; + let roots = target_roots(s, p)?; + let ids = s.edit("Remove Effect", |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + if index < n.appearance.effects.len() { + n.appearance.effects.remove(index); + done.push(*id); + } + } + if done.is_empty() { + return Err(bad(C, format!("no effect at index {index}"))); + } + Ok(done) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn set_params(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.setParams"; + let index = index_param(p, C)?; + let params = p.get("params").cloned().unwrap_or(Value::Null); + if !params.is_null() && !params.is_object() { + return Err(bad(C, "`params` must be an object")); + } + let visible = p.get("visible").and_then(Value::as_bool); + let roots = target_roots(s, p)?; + let ids = s.edit("Effect Options", |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(e) = n.appearance.effects.get_mut(index) else { continue }; + if let (Value::Object(new), cur) = (¶ms, &mut e.params) { + if !cur.is_object() { + *cur = effects::merged_params(&e.id, &Value::Null); + } + if let Value::Object(m) = cur { + for (k, v) in new { + m.insert(k.clone(), v.clone()); + } + } + } + if let Some(v) = visible { + e.visible = v; + } + done.push(*id); + } + if done.is_empty() { + return Err(bad(C, format!("no effect at index {index}"))); + } + Ok(done) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +/// Geometry of a path or compound path node. +fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + _ => None, + } +} + +/// Bake the object-level geometry effects of `id` (and, recursively, of group members). +fn expand_node(d: &mut drawcraft_doc::Document, id: NodeId, out: &mut Vec) { + let Some(n) = d.node(id).cloned() else { return }; + if let Some(children) = n.children() + && matches!(n.kind, NodeKind::Group { .. }) + { + for c in children.iter().map(|c| c.id).collect::>() { + expand_node(d, c, out); + } + return; + } + if !effects::has_geometry(&n.appearance.effects) { + return; + } + let Some((path, rule)) = node_geometry(&n) else { return }; + let Some(bounds) = path.bounds() else { return }; + let w = n.appearance.stroke_width(); + let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }; + let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx); + let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 { + let children = baked + .subpaths + .into_iter() + .map(|sp| { + let cid = d.alloc_id(); + Arc::new(Node::path(cid, PathData::single(sp), Default::default())) + }) + .collect(); + NodeKind::Compound { children, rule } + } else { + NodeKind::Path { path: baked, rule, live: None, clipping: false, guide: false } + }; + let Some(m) = d.node_mut(id) else { return }; + m.kind = kind; + m.appearance.effects.retain(|e| !effects::is_geometry(&e.id)); + out.push(id); +} + +fn expand_appearance(s: &mut Session, p: &Value) -> Result { + let roots = target_roots(s, p)?; + let ids = s.edit("Expand Appearance", |d, _| { + let mut out = vec![]; + for id in &roots { + expand_node(d, *id, &mut out); + } + if out.is_empty() { + return Err(EngineError::Other("Expand Appearance: no geometry effects to expand".into())); + } + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +#[cfg(test)] +mod tests { + use drawcraft_doc::NodeKind; + use serde_json::json; + + use crate::{NodeId, Session}; + + fn session_with_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) + } + + fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() + } + + #[test] + fn apply_uses_catalog_defaults_and_undoes() { + let (mut s, id) = session_with_rect(); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); + assert_eq!(r["index"], json!(0)); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].params["x"], json!(3)); + assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); + assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); + } + + #[test] + fn list_remove_and_set_params() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let l = s.execute("effect.list", &json!({})).unwrap(); + assert!(l["catalog"].as_array().unwrap().len() >= 34); + assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); + let e = &node(&s, id).appearance.effects[0]; + assert_eq!(e.params["angle"], json!(45)); + assert!(!e.visible); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); + assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); + } + + #[test] + fn expand_appearance_bakes_geometry_and_keeps_raster() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + let b = n.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].id, "stylize.dropShadow"); + assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); + // Nothing left to expand. + assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); + } + + #[test] + fn expand_transform_copies_makes_compound() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Compound { .. })); + let b = n.geometric_bounds().unwrap(); + assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 00000000..1455189c --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -0,0 +1,130 @@ +//! Document I/O and transactions: open/save/export by path or bytes (base64), and `command.batch`. +//! +//! These live in the engine so every frontend (UI, CLI, MCP headless) shares them. + +use drawcraft_doc::Document; +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "document.open", + "Open Document", + [], + None, + "{path} or {name, dataBase64} → {index}; .drawcraft, .svg (PDF/.ai via drawcraft-pdf when available)", + always, + open + ), + cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save), + cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg} → {dataBase64 | text}", has_doc, serialize), + cmd!("document.export", "Export Document", [], None, "{format: svg|png|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export), + cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch), + ] +} + +fn load(name: &str, bytes: &[u8]) -> Result { + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".drawcraft") || drawcraft_format::sniff(bytes) { + return drawcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string())); + } + if lower.ends_with(".svg") || bytes.starts_with(b" Result { + let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) { + (Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())), + (None, Some(b64)) => ( + str_param(p, "name").unwrap_or("untitled.drawcraft").to_string(), + drawcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?, + None, + ), + _ => return Err(bad("document.open", "give path or dataBase64")), + }; + let doc = load(&name, &bytes)?; + let native = name.to_ascii_lowercase().ends_with(".drawcraft"); + let i = s.add_document(doc, if native { path } else { None }); + Ok(json!({ "index": i })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path") + .map(str::to_string) + .or_else(|| s.active().and_then(|d| d.path.clone())) + .ok_or_else(|| bad("document.save", "no path"))?; + let bytes = drawcraft_format::save(&s.doc()?.doc, true); + std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let st = s.doc_mut()?; + st.path = Some(path.clone()); + st.saved_revision = st.revision; + Ok(json!({ "path": path })) +} + +pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result> { + let doc = &s.doc()?.doc; + Ok(match format { + "drawcraft" => drawcraft_format::save(doc, true), + "svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), + "png" => { + let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), false).to_png() + } + other => return Err(EngineError::Other(format!("unknown format `{other}`"))), + }) +} + +fn serialize(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "format").unwrap_or("drawcraft"); + let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?; + if f == "svg" { + return Ok(json!({ "text": String::from_utf8_lossy(&bytes) })); + } + Ok(json!({ "dataBase64": drawcraft_format::base64_encode(&bytes) })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?; + let f = str_param(p, "format") + .map(str::to_string) + .unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into())); + let bytes = encode(s, &f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?; + std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({ "path": path, "bytes": bytes.len() })) +} + +fn batch(s: &mut Session, p: &Value) -> Result { + let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); + let label = str_param(p, "label").unwrap_or("Batch").to_string(); + s.begin_interaction(&label)?; + let mut results = vec![]; + for c in &cmds { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + if id == "command.batch" { + s.cancel_interaction()?; + return Err(bad("command.batch", "batches cannot nest")); + } + let params = c.get("params").cloned().unwrap_or(json!({})); + match s.execute(id, ¶ms) { + Ok(v) => results.push(v), + Err(e) => { + s.cancel_interaction()?; + return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len()))); + } + } + } + // Commit as one undo step (the interaction's "preview" is the batch itself). + if let Some(it) = s.doc_mut()?.interaction.as_mut() { + it.preview = Some(("command.batch".into(), p.clone())); + } + s.commit_interaction()?; + Ok(json!({ "results": results })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b7c21db9..25cadc68 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,9 +2,9 @@ mod create; mod draw2; -mod effectcmd; -mod xform; mod edit; +mod effectcmd; +mod file; mod layer; mod object; mod paint; @@ -12,6 +12,7 @@ mod path; mod pathops; mod select; mod typecmd; +mod xform; use drawcraft_color::Color; use drawcraft_doc::NodeId; @@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] { SPECS.get_or_init(|| { let mut v = Vec::new(); v.extend(edit::specs()); + v.extend(file::specs()); v.extend(create::specs()); v.extend(object::specs()); v.extend(path::specs()); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index aedbf66b..fa093d9e 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -1,7 +1,355 @@ -//! Commands backing transform/utility tools (eyedropper, gradient, artboard…). +//! Commands backing transform/utility tools: free distort, eyedropper, gradient vector, artboard move. -use super::CommandSpec; +use std::sync::Arc; + +use drawcraft_color::{Gradient, GradientGeom, GradientPaint, Paint}; +use drawcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use drawcraft_geom::{Affine, Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; +use crate::EngineError; pub fn specs() -> Vec { - vec![] + vec![ + cmd!( + "object.distort", + "Free Distort", + [], + None, + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles", + has_doc, + distort + ), + cmd!( + "appearance.copyFrom", + "Eyedropper", + [], + None, + "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", + has_doc, + copy_from + ), + cmd!( + "paint.sampleColor", + "Sample Color", + [], + None, + "{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)", + has_doc, + sample_color + ), + cmd!( + "paint.setGradientGeom", + "Gradient Vector", + [], + None, + "{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)", + has_doc, + set_gradient_geom + ), + cmd!( + "artboard.move", + "Move Artboard", + [], + None, + "{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)", + has_doc, + artboard_move + ), + ] +} + +// ---------- projective distort ---------- + +/// A 2D projective transform (homography) mapping the unit square to a quad. +#[derive(Clone, Copy, Debug)] +pub struct Projective { + m: [f64; 8], + src: Rect, +} + +impl Projective { + /// Map `src`'s corners (TL, TR, BR, BL) onto `q`. + pub fn from_rect(src: Rect, q: [Point; 4]) -> Self { + let [p0, p1, p2, p3] = q; + let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x); + let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y); + let (g, h) = if dx3.abs() < 1e-12 && dy3.abs() < 1e-12 { + (0.0, 0.0) + } else { + let den = dx1 * dy2 - dx2 * dy1; + if den.abs() < 1e-12 { (0.0, 0.0) } else { ((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den) } + }; + let a = p1.x - p0.x + g * p1.x; + let b = p3.x - p0.x + h * p3.x; + let d = p1.y - p0.y + g * p1.y; + let e = p3.y - p0.y + h * p3.y; + Self { m: [a, b, p0.x, d, e, p0.y, g, h], src } + } + + pub fn apply(&self, p: Point) -> Point { + let [a, b, c, d, e, f, g, h] = self.m; + let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 }; + let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 }; + let w = g * u + h * v + 1.0; + let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w }; + Point::new((a * u + b * v + c) / w, (d * u + e * v + f) / w) + } + + /// Affine approximation at a point (for objects that can't be warped: text, images, symbols). + fn affine_near(&self, p: Point) -> Affine { + let eps = 1.0; + let o = self.apply(p); + let ex = self.apply(p + Vec2::new(eps, 0.0)) - o; + let ey = self.apply(p + Vec2::new(0.0, eps)) - o; + let lin = Affine::new([ex.x / eps, ex.y / eps, ey.x / eps, ey.y / eps, 0.0, 0.0]); + Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) + } +} + +fn warp_paints(ap: &mut Appearance, pr: &Projective) { + for it in &mut ap.items { + let p = match it { + drawcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, + drawcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = p + && let Some(geom) = &mut g.geom + { + geom.start = pr.apply(geom.start); + geom.end = pr.apply(geom.end); + } + } +} + +fn warp_node(n: &mut Node, pr: &Projective) { + warp_paints(&mut n.appearance, pr); + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + for sp in &mut path.subpaths { + for a in &mut sp.anchors { + a.p = pr.apply(a.p); + a.h_in = pr.apply(a.h_in); + a.h_out = pr.apply(a.h_out); + } + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + warp_node(Arc::make_mut(c), pr); + } + } + _ => { + if let Some(b) = n.geometric_bounds() { + n.transform(pr.affine_near(b.center()), false); + } + } + } +} + +fn distort(s: &mut Session, p: &Value) -> Result { + let corners: Vec = p + .get("corners") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect()) + .unwrap_or_default(); + if corners.len() != 4 { + return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)")); + } + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let src = match p.get("from").and_then(Value::as_array) { + Some(a) if a.len() == 4 => { + let f: Vec = a.iter().filter_map(Value::as_f64).collect(); + if f.len() != 4 { + return Err(bad("object.distort", "from must be [x0,y0,x1,y1]")); + } + Rect::new(f[0], f[1], f[2], f[3]) + } + _ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?, + }; + if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { + return Err(EngineError::Other("cannot distort a zero-size bounding box".into())); + } + let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]); + s.edit("Free Distort", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + warp_node(n, &pr); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +// ---------- eyedropper ---------- + +/// Leaves whose appearance a paint command changes (groups expand to their contents; compound +/// paths own their children's appearance). +fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { + if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { + out.push(c.id) + } + }), + _ => out.push(*id), + } + } + let comp: Vec = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); + out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); + Ok(out) +} + +fn copy_from(s: &mut Session, p: &Value) -> Result { + let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; + let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; + let appearance = match &src.kind { + NodeKind::Text(t) => match t.runs.first() { + Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), + None => src.appearance.clone(), + }, + _ => src.appearance.clone(), + }; + s.paint.fill = appearance.fill_paint(); + s.paint.stroke = appearance.stroke_paint(); + if appearance.stroke().is_some() { + s.paint.stroke_width = appearance.stroke_width(); + } + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let mut targets = leaf_targets(s, &ids)?; + targets.retain(|id| *id != src_id); + if targets.is_empty() { + return Ok(json!({ "ids": [] })); + } + let (opacity, blend) = (src.opacity, src.blend); + s.edit("Eyedropper", |d, _| { + for id in &targets { + let Some(n) = d.node_mut(*id) else { continue }; + n.opacity = opacity; + n.blend = blend; + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.fill = appearance.fill_paint(); + r.style.stroke = appearance.stroke_paint(); + r.style.stroke_width = appearance.stroke_width(); + } + continue; + } + n.appearance = appearance.clone(); + } + Ok(()) + })?; + Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) +} + +fn sample_color(s: &mut Session, p: &Value) -> Result { + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?; + let mut q = json!({ "color": c }); + if let Some(ids) = p.get("ids") { + q["ids"] = ids.clone(); + } + let id = if stroke { "paint.setStroke" } else { "paint.setFill" }; + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, &q) +} + +// ---------- gradient tool ---------- + +fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { + let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?; + let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?; + let stroke = bool_or(p, "stroke", false); + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let targets = leaf_targets(s, &ids)?; + if targets.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + s.edit("Gradient", |d, _| { + for id in &targets { + let Some(n) = d.node_mut(*id) else { continue }; + if matches!(n.kind, NodeKind::Text(_)) { + continue; + } + let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; + let mut gp = match cur { + Paint::Gradient(g) => *g, + _ => GradientPaint::new(Gradient::default()), + }; + let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); + gp.geom = Some(GradientGeom { start, end, aspect }); + gp.angle = GradientGeom { start, end, aspect }.angle_deg(); + let paint = Paint::Gradient(Box::new(gp)); + if stroke { + n.appearance.set_stroke(paint); + } else { + n.appearance.set_fill(paint); + } + } + Ok(()) + })?; + Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) +} + +// ---------- artboards ---------- + +fn artboard_move(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize; + let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let move_art = bool_or(p, "moveArt", false); + let st = s.doc()?; + let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + // Top-level objects (children of layers) lying entirely inside the artboard. + let mut art = vec![]; + if move_art { + fn collect(n: &Node, rect: Rect, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if c.locked { + continue; + } + if c.is_layer() { + collect(c, rect, out); + } else if let Some(b) = c.geometric_bounds() + && rect.contains(Point::new(b.x0, b.y0)) + && rect.contains(Point::new(b.x1, b.y1)) + { + out.push(c.id); + } + } + } + for l in &st.doc.layers { + if !l.locked { + collect(l, rect, &mut art); + } + } + } + let scale_strokes = s.prefs.scale_strokes; + s.edit("Move Artboard", |d, _| { + let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + a.rect = a.rect + dv; + for id in &art { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(dv), scale_strokes); + } + } + Ok(()) + })?; + Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f91daaea..cfd0840f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -354,4 +354,10 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_draw2; +#[cfg(test)] +mod tests_file; +#[cfg(test)] mod tests_pathops; +#[cfg(test)] +mod tests_xform; diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs new file mode 100644 index 00000000..effbde49 --- /dev/null +++ b/crates/engine/src/tests_draw2.rs @@ -0,0 +1,391 @@ +//! Drawing / path-editing tools driven through the session (tools → actions → `path.*` commands). + +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::{FillRule, PathData, Point}; +use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use serde_json::json; + +use super::*; +use crate::tooling::{UiRequest, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..Default::default() } +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn line(s: &mut Session, x1: f64, y1: f64, x2: f64, y2: f64) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn paths(s: &Session) -> Vec<(NodeId, PathData)> { + let mut v = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + v.push((n.id, path.clone())); + } + }); + v +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn drag(s: &mut Session, tool: &str, pts: &[(f64, f64)], up_mods: Mods) -> Vec { + let v = view(); + s.select_tool(tool, v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1), v).unwrap(); + for &(x, y) in &pts[1..] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap(); + } + let l = pts[pts.len() - 1]; + s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(up_mods), v).unwrap() +} + +fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec { + drag(s, tool, &[(x, y)], Mods::default()) +} + +/// A wavy stroke sampled every 2 pt. +fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> { + let n = ((x1 - x0) / 2.0) as usize; + (0..=n).map(|i| x0 + i as f64 * 2.0).map(|x| (x, y + 20.0 * ((x - x0) / 40.0).sin())).collect() +} + +fn near(a: Point, b: Point) -> bool { + a.distance(b) < 1e-6 +} + +fn area(pd: &PathData) -> f64 { + drawcraft_pathops::area(pd, FillRule::NonZero) +} + +#[test] +fn pencil_fits_freehand_stroke_as_one_undo_step() { + let mut s = session(); + let pts = wave(100.0, 400.0, 200.0); + let undo_before = s.doc().unwrap().history.undo.len(); + drag(&mut s, "pencil", &pts, Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 1); + let p = &ps[0].1; + assert!(!p.is_closed()); + assert!(p.anchor_count() >= 2 && p.anchor_count() < pts.len() / 4, "{} anchors", p.anchor_count()); + let first = p.subpaths[0].anchors[0].p; + assert!(first.distance(Point::new(100.0, 200.0)) < 1e-6); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1); + // Unfilled, stroked. + let n = s.doc().unwrap().doc.node(ps[0].0).unwrap().clone(); + assert!(n.appearance.fill_paint().is_none()); + assert!(!n.appearance.stroke_paint().is_none()); +} + +#[test] +fn pencil_alt_closes_path() { + let mut s = session(); + let mut pts: Vec<(f64, f64)> = + (0..=36).map(|i| (i as f64 * 10.0f64).to_radians()).map(|a| (300.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())).collect(); + pts.pop(); + drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() }); + let ps = paths(&s); + assert!(ps[0].1.is_closed()); +} + +#[test] +fn pencil_continues_selected_open_path() { + let mut s = session(); + let id = line(&mut s, 100.0, 100.0, 200.0, 100.0); + drag(&mut s, "pencil", &[(202.0, 101.0), (230.0, 120.0), (260.0, 150.0), (300.0, 160.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 1, "no new object"); + let p = path(&s, id); + assert!(p.anchor_count() >= 3); + let last = p.subpaths[0].anchors.last().unwrap().p; + assert!(last.distance(Point::new(300.0, 160.0)) < 1e-6); + assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0)); +} + +#[test] +fn paintbrush_uses_stroke_blob_brush_fills() { + let mut s = session(); + drag(&mut s, "paintbrush", &wave(50.0, 200.0, 100.0), Mods::default()); + let n = s.doc().unwrap().doc.node(paths(&s)[0].0).unwrap().clone(); + assert!(n.appearance.fill_paint().is_none()); + s.execute("select.none", &json!({})).unwrap(); + drag(&mut s, "blobBrush", &[(100.0, 400.0), (200.0, 400.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 2); + let blob = &ps[1].1; + assert!(blob.is_closed()); + // 100 × 10 capsule ≈ 1000 + π·25. + assert!((area(blob) - (1000.0 + std::f64::consts::PI * 25.0)).abs() < 10.0, "{}", area(blob)); + let bn = s.doc().unwrap().doc.node(ps[1].0).unwrap().clone(); + assert!(bn.appearance.stroke_paint().is_none() && !bn.appearance.fill_paint().is_none()); + // A second overlapping stroke of the same colour merges. + drag(&mut s, "blobBrush", &[(150.0, 380.0), (150.0, 450.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 2, "merged into the first blob"); + assert!(area(&ps[1].1) > 1500.0); +} + +#[test] +fn curvature_clicks_build_smooth_path() { + let mut s = session(); + let v = view(); + s.select_tool("curvature", v).unwrap(); + for (x, y) in [(100.0, 300.0), (200.0, 200.0), (300.0, 300.0)] { + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); + } + let ps = paths(&s); + assert_eq!(ps.len(), 1); + let sp = &ps[0].1.subpaths[0]; + assert_eq!(sp.anchors.len(), 3); + assert_eq!(sp.anchors[1].kind, drawcraft_geom::AnchorKind::Smooth); + // Alt-click the middle point → corner. + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 200.0), v).unwrap(); + assert!(!path(&s, ps[0].0).subpaths[0].anchors[1].has_out()); + // Drag the middle point. + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap(); + assert_eq!(path(&s, ps[0].0).subpaths[0].anchors[1].p, Point::new(200.0, 150.0)); + // Esc ends; the next click starts a new path. + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 500.0), v).unwrap(); + assert_eq!(paths(&s).len(), 2); +} + +#[test] +fn curvature_click_first_point_closes() { + let mut s = session(); + let v = view(); + s.select_tool("curvature", v).unwrap(); + for (x, y) in [(100.0, 100.0), (200.0, 100.0), (150.0, 200.0), (100.0, 100.0)] { + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); + } + let p = &paths(&s)[0].1; + assert!(p.is_closed()); + assert_eq!(p.anchor_count(), 3); +} + +#[test] +fn add_and_delete_anchor_tools() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + click(&mut s, "addAnchor", 150.0, 100.0); + let p = path(&s, id); + assert_eq!(p.anchor_count(), 5); + assert!(near(p.subpaths[0].anchors[1].p, Point::new(150.0, 100.0))); + click(&mut s, "deleteAnchor", 150.0, 100.0); + click(&mut s, "deleteAnchor", 200.0, 200.0); + let p = path(&s, id); + assert_eq!(p.anchor_count(), 3); + assert!(p.is_closed()); +} + +#[test] +fn delete_anchor_on_circle_keeps_curve() { + let mut s = session(); + let r = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let before = path(&s, id); + let a = before.subpaths[0].anchors[1].p; + click(&mut s, "deleteAnchor", a.x, a.y); + let p = path(&s, id); + assert_eq!(p.anchor_count(), before.anchor_count() - 1); + // The remaining curve still bulges out towards the removed anchor. + let b = p.bounds().unwrap(); + assert!(b.width() > 150.0 && b.height() > 150.0, "{b:?}"); +} + +#[test] +fn anchor_point_tool_converts() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + drag(&mut s, "anchorPoint", &[(100.0, 100.0), (130.0, 80.0)], Mods::default()); + let a = path(&s, id).subpaths[0].anchors[0]; + assert_eq!(a.kind, drawcraft_geom::AnchorKind::Smooth); + assert_eq!(a.h_out, Point::new(130.0, 80.0)); + assert_eq!(a.h_in, Point::new(70.0, 120.0)); + // Click the smooth anchor → corner again. + click(&mut s, "anchorPoint", 100.0, 100.0); + let a = path(&s, id).subpaths[0].anchors[0]; + assert!(!a.has_in() && !a.has_out()); + // Drag a segment → it bends so the grabbed point follows. + drag(&mut s, "anchorPoint", &[(150.0, 200.0), (150.0, 240.0)], Mods::default()); + let p = path(&s, id); + let (_, _, _, q, d) = p.nearest(Point::new(150.0, 240.0)).unwrap(); + assert!(d < 0.5, "{q:?}"); +} + +#[test] +fn scissors_opens_closed_path() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + click(&mut s, "scissors", 200.0, 150.0); + let p = path(&s, id); + assert!(!p.is_closed()); + assert_eq!(p.anchor_count(), 6); + assert!(near(p.subpaths[0].anchors[0].p, Point::new(200.0, 150.0))); + assert!(near(p.subpaths[0].anchors[5].p, Point::new(200.0, 150.0))); +} + +#[test] +fn scissors_splits_open_path_in_two() { + let mut s = session(); + let id = line(&mut s, 100.0, 100.0, 300.0, 100.0); + click(&mut s, "scissors", 150.0, 100.0); + let ps = paths(&s); + assert_eq!(ps.len(), 2); + assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0))); + assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0))); + assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0)); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + // Clicking an end point is refused without panicking. + assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err()); +} + +#[test] +fn knife_cuts_rect_into_two_pieces() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.none", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + drag(&mut s, "knife", &[(200.0, 50.0), (210.0, 150.0), (200.0, 250.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 2); + let total: f64 = ps.iter().map(|(_, p)| area(p)).sum(); + assert!((total - 20000.0).abs() < 50.0, "{total}"); + assert!(ps.iter().all(|(_, p)| p.is_closed() && area(p) > 9000.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paths(&s).len(), 1); +} + +#[test] +fn knife_partial_cut_does_nothing() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute("path.knife", &json!({"points": [[200, 50], [200, 150]]})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(path(&s, id).anchor_count(), 4); +} + +#[test] +fn eraser_splits_shapes_and_lines() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 100.0); + line(&mut s, 100.0, 300.0, 300.0, 300.0); + s.execute("select.none", &json!({})).unwrap(); + drag(&mut s, "eraser", &[(200.0, 50.0), (200.0, 350.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 4, "two rect halves + two line pieces"); + let closed: Vec<&PathData> = ps.iter().map(|p| &p.1).filter(|p| p.is_closed()).collect(); + assert_eq!(closed.len(), 2); + let total: f64 = closed.iter().map(|p| area(p)).sum(); + assert!((total - (20000.0 - 1000.0)).abs() < 50.0, "{total}"); + let open: Vec<&PathData> = ps.iter().map(|p| &p.1).filter(|p| !p.is_closed()).collect(); + let len: f64 = open.iter().map(|p| p.length()).sum(); + assert!((len - 190.0).abs() < 0.1, "{len}"); +} + +#[test] +fn eraser_removes_small_shape_entirely() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 4.0, 4.0); + s.execute("select.none", &json!({})).unwrap(); + s.execute("path.eraseRegion", &json!({"points": [[102, 102]], "size": 20})).unwrap(); + assert!(paths(&s).is_empty()); +} + +#[test] +fn path_eraser_splits_selected_path() { + let mut s = session(); + let id = line(&mut s, 100.0, 100.0, 300.0, 100.0); + drag(&mut s, "pathEraser", &[(180.0, 100.0), (220.0, 100.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 2); + assert!(path(&s, id).subpaths[0].anchors[1].p.x < 180.0); +} + +#[test] +fn smooth_tool_reduces_jagged_path() { + let mut s = session(); + let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 100.0 + i as f64 * 5.0, "y": 200.0 + if i % 2 == 0 { 0.0 } else { 1.5 }})).collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + drag(&mut s, "smooth", &[(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)], Mods::default()); + let p = path(&s, id); + assert!(p.anchor_count() < 20, "{}", p.anchor_count()); + assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); +} + +#[test] +fn join_tool_joins_two_lines() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + line(&mut s, 205.0, 100.0, 300.0, 150.0); + s.execute("select.none", &json!({})).unwrap(); + drag(&mut s, "join", &[(202.0, 90.0), (202.0, 110.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 1); + let p = path(&s, a); + assert_eq!(p.anchor_count(), 3); + assert_eq!(p.subpaths[0].anchors[1].p, Point::new(202.5, 100.0)); + assert_eq!(p.subpaths[0].anchors[2].p, Point::new(300.0, 150.0)); +} + +#[test] +fn line_family_tools_draw_and_click_asks_dialog() { + let mut s = session(); + drag(&mut s, "arc", &[(100.0, 100.0), (200.0, 150.0)], Mods::default()); + drag(&mut s, "spiral", &[(400.0, 300.0), (450.0, 300.0)], Mods::default()); + drag(&mut s, "rectangularGrid", &[(100.0, 300.0), (200.0, 400.0)], Mods::default()); + drag(&mut s, "polarGrid", &[(500.0, 100.0), (600.0, 200.0)], Mods::default()); + let top = s.doc().unwrap().doc.layers[0].children().unwrap().len(); + assert_eq!(top, 4); + let b = paths(&s)[0].1.bounds().unwrap(); + assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}"); + let ui = click(&mut s, "polarGrid", 10.0, 10.0); + assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]); +} + +#[test] +fn draw2_commands_reject_bad_params() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + for (cmd, p) in [ + ("path.freehand", json!({})), + ("path.freehand", json!({"points": [[1, 2]]})), + ("path.curvature", json!({"points": []})), + ("path.removeAnchor", json!({"id": id.0, "anchor": 99})), + ("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})), + ("path.reshapeSegment", json!({"id": id.0, "segment": 42})), + ("path.split", json!({"id": id.0})), + ("path.split", json!({"id": id.0, "segment": 0, "t": "x"})), + ("path.knife", json!({"points": [[0, 0]]})), + ("path.eraseRegion", json!({"points": [[0, 0]], "size": -1})), + ("path.blob", json!({"points": "nope"})), + ("path.joinScrub", json!({"points": []})), + ] { + let before = s.doc().unwrap().history.undo.len(); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step"); + } +} diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs new file mode 100644 index 00000000..e425a74f --- /dev/null +++ b/crates/engine/src/tests_file.rs @@ -0,0 +1,80 @@ +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +#[test] +fn batch_is_one_undo_step() { + let mut s = session(); + let r = s + .execute( + "command.batch", + &json!({"label": "Logo", "commands": [ + {"command": "paint.setFill", "params": {"color": "#ff0000"}}, + {"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 50, "height": 50}}, + {"command": "shape.ellipse", "params": {"x": 60, "y": 0, "width": 50, "height": 50}}, + ]}), + ) + .unwrap(); + assert_eq!(r["results"].as_array().unwrap().len(), 3); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), 1); + assert_eq!(st.history.undo[0].label, "Logo"); + assert_eq!(st.doc.layers[0].children().unwrap().len(), 2); + assert_eq!(s.journal.iter().filter(|(c, _)| c == "command.batch").count(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 0); +} + +#[test] +fn batch_rolls_back_on_error() { + let mut s = session(); + let r = s.execute( + "command.batch", + &json!({"commands": [ + {"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 50, "height": 50}}, + {"command": "no.such.command", "params": {}}, + ]}), + ); + assert!(r.is_err()); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 0); + assert!(s.doc().unwrap().history.undo.is_empty()); + assert!(!s.in_interaction()); +} + +#[test] +fn serialize_and_reopen() { + let mut s = session(); + s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap(); + let b64 = s.execute("document.serialize", &json!({"format": "drawcraft"})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(svg.contains(" 100); +} + +#[test] +fn save_and_open_path() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap(); + let dir = std::env::temp_dir().join(format!("dc-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.drawcraft"); + s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref())); + let svg = dir.join("t.svg"); + s.execute("document.export", &json!({"path": svg.to_string_lossy()})).unwrap_or_default(); + s.execute("document.export", &json!({"format": "svg", "path": svg.to_string_lossy()})).unwrap(); + assert!(std::fs::read_to_string(&svg).unwrap().contains(" Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) -> Vec { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + let mut out = vec![]; + for (i, (x, y)) in pts.iter().enumerate() { + let kind = if i == 0 { + PointerKind::Down + } else if i == pts.len() - 1 { + PointerKind::Up + } else { + PointerKind::Drag + }; + out.extend(s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap()); + } + out +} + +fn close(a: Rect, b: Rect) -> bool { + (a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6 +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn rotate_tool_drag_rotates_about_center_one_undo() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let n = undo_len(&s); + gesture(&mut s, "rotate", &[(250.0, 125.0), (200.0, 175.0), (150.0, 225.0), (150.0, 225.0)], Mods::default()); + // 90° about (150,125): 100×50 becomes 50×100. + assert!(close(bounds(&s, a), Rect::new(125.0, 75.0, 175.0, 175.0)), "{:?}", bounds(&s, a)); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0))); +} + +#[test] +fn rotate_tool_alt_release_copies() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let alt = Mods { alt: true, ..Default::default() }; + let v = ViewInfo::default(); + s.select_tool("rotate", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 225.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 225.0).with_mods(alt), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); +} + +#[test] +fn rotate_tool_alt_click_requests_dialog() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let alt = Mods { alt: true, ..Default::default() }; + let ui = gesture(&mut s, "rotate", &[(10.0, 20.0), (10.0, 20.0)], alt); + assert_eq!(ui, vec![UiRequest::Dialog("rotate".into(), json!({"angle": 0, "origin": [10.0, 20.0]}))]); +} + +#[test] +fn scale_tool_click_origin_then_drag() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + gesture(&mut s, "scale", &[(100.0, 100.0), (100.0, 100.0)], Mods::default()); + gesture(&mut s, "scale", &[(200.0, 200.0), (250.0, 250.0), (300.0, 300.0), (300.0, 300.0)], Mods::default()); + assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 300.0)), "{:?}", bounds(&s, a)); +} + +#[test] +fn reflect_and_shear_tools() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + // Reflect across a vertical axis through the centre (150,125): bounds unchanged, geometry mirrored. + gesture(&mut s, "reflect", &[(150.0, 50.0), (150.0, 40.0), (150.0, 20.0), (150.0, 20.0)], Mods::default()); + assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0))); + // Shear horizontally: top edge shifts right by 25 relative to the centre row. + gesture(&mut s, "shear", &[(150.0, 100.0), (160.0, 100.0), (175.0, 100.0), (175.0, 100.0)], Mods::default()); + let b = bounds(&s, a); + assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn distort_command_maps_corners() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap(); + let d = &s.doc().unwrap().doc; + let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + for want in [Point::new(10.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(pts.iter().any(|p| p.distance(want) < 1e-6), "{pts:?} missing {want:?}"); + } + assert!(s.execute("object.distort", &json!({"corners": [[0, 0]]})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); +} + +#[test] +fn distort_perspective_midpoint_is_projective() { + // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real + // perspective warp the centre lands where the quad's diagonals cross (y = 100/6), not at y = 50. + let mut s = session(); + let probe = rect(&mut s, 49.999, 49.999, 0.002, 0.002); + s.execute("object.distort", &json!({"corners": [[40, 0], [60, 0], [100, 100], [0, 100]], "from": [0, 0, 100, 100], "ids": [probe.0]})).unwrap(); + let c = bounds(&s, probe).center(); + assert!((c.x - 50.0).abs() < 1e-6); + assert!((c.y - 100.0 / 6.0).abs() < 1e-3, "{c:?}"); +} + +#[test] +fn free_transform_distort_mode_via_pointer() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let v = ViewInfo::default(); + s.select_tool("freeTransform", v).unwrap(); + s.set_tool_option("mode", &json!("distort")); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0), v).unwrap(); + let d = &s.doc().unwrap().doc; + let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + assert!(pts.iter().any(|p| p.distance(Point::new(250.0, 260.0)) < 1e-6), "{pts:?}"); + assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6)); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); +} + +#[test] +fn free_transform_side_handle_scales() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + gesture(&mut s, "freeTransform", &[(200.0, 150.0), (250.0, 150.0), (300.0, 150.0), (300.0, 150.0)], Mods::default()); + assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 200.0))); +} + +#[test] +fn eyedropper_copies_appearance_to_selection_and_defaults() { + let mut s = session(); + let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("object.setProps", &json!({"opacity": 0.5})).unwrap(); + let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.default", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods::default()); + let d = &s.doc().unwrap().doc; + let n = d.node(dst).unwrap(); + assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap())); + assert_eq!(n.appearance.stroke_width(), 4.0); + assert!((n.opacity - 0.5).abs() < 1e-6); + assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#ff0000").unwrap())); + assert_eq!(s.paint.stroke_width, 4.0); + let _ = src; +} + +#[test] +fn eyedropper_shift_samples_color_into_active_proxy() { + let mut s = session(); + rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.default", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("paint.toggleActive", &json!({})).unwrap(); // stroke active + gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods { shift: true, ..Default::default() }); + let n = s.doc().unwrap().doc.node(dst).unwrap().clone(); + assert_eq!(n.appearance.stroke_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::WHITE)); +} + +#[test] +fn gradient_tool_sets_vector_on_solid_fill() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + gesture(&mut s, "gradient", &[(100.0, 150.0), (150.0, 150.0), (200.0, 100.0), (200.0, 100.0)], Mods::default()); + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("expected gradient") }; + let geom = g.geom.unwrap(); + assert_eq!(geom.start, Point::new(100.0, 150.0)); + assert_eq!(geom.end, Point::new(200.0, 100.0)); + assert_eq!(undo_len(&s), 2); + // Changing an existing gradient keeps its stops. + s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [10, 0]})).unwrap(); + let Paint::Gradient(g2) = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint() else { panic!() }; + assert_eq!(g2.gradient, g.gradient); + assert!(s.execute("paint.setGradientGeom", &json!({"start": [0, 0]})).is_err()); +} + +#[test] +fn artboard_tool_moves_with_art_creates_and_deletes() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let outside = rect(&mut s, 900.0, 100.0, 50.0, 50.0); + s.execute("select.none", &json!({})).unwrap(); + gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards[0].rect, Rect::new(50.0, 30.0, 850.0, 630.0)); + assert!(close(bounds(&s, a), Rect::new(150.0, 130.0, 200.0, 180.0))); + assert!(close(bounds(&s, outside), Rect::new(900.0, 100.0, 950.0, 150.0))); + // Draw a new artboard on the pasteboard. + gesture(&mut s, "artboard", &[(1000.0, 0.0), (1100.0, 50.0), (1200.0, 100.0), (1200.0, 100.0)], Mods::default()); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 2); + assert_eq!(s.tool_options()["active"], 1); + assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0)); + // Delete it. + s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); + // The last artboard can't be deleted. + s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); +} + +#[test] +fn artboard_resize_via_handle_and_move_command() { + let mut s = session(); + gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default()); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0)); + s.execute("artboard.move", &json!({"index": 0, "dx": -10, "dy": 5})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(-10.0, 5.0, 890.0, 605.0)); + assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err()); +} + +#[test] +fn magic_wand_selects_same_fill() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0); + let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [c.0]})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + gesture(&mut s, "magicWand", &[(20.0, 20.0), (20.0, 20.0)], Mods::default()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + gesture(&mut s, "magicWand", &[(100.0, 20.0), (100.0, 20.0)], Mods { shift: true, ..Default::default() }); + assert_eq!(s.doc().unwrap().selection.len(), 3); +} + +#[test] +fn lasso_selects_anchor_subset() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.none", &json!({})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, vec![a]); + assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2)); +} + +#[test] +fn measure_tool_leaves_document_untouched() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let rev = s.doc().unwrap().revision; + let n = undo_len(&s); + gesture(&mut s, "measure", &[(0.0, 0.0), (30.0, 40.0), (30.0, 40.0)], Mods::default()); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo_len(&s), n); + assert!((s.tool_options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); + assert!(!s.overlays(ViewInfo::default()).is_empty()); +} + +#[test] +fn copy_from_requires_source() { + let mut s = session(); + assert!(s.execute("appearance.copyFrom", &json!({})).is_err()); + assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err()); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 25316e4d..1e4c58e6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -49,7 +49,13 @@ struct File { /// Serialize a document (pretty = human-diffable). pub fn save(doc: &Document, pretty: bool) -> Vec { let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect(); - let f = File { format: "drawcraft".into(), version: VERSION, generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), document: doc.clone(), images }; + let f = File { + format: "drawcraft".into(), + version: VERSION, + generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), + document: doc.clone(), + images, + }; if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index f9be38ce..69f6fa6d 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -11,5 +11,13 @@ workspace = true [dependencies] drawcraft-geom = { workspace = true } drawcraft-doc = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-text = { workspace = true } kurbo = { workspace = true } thiserror = { workspace = true } +image = { workspace = true } +krilla = { workspace = true } +hayro-syntax = "0.7.2" +hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } +log = { workspace = true } +smallvec = "1" diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs new file mode 100644 index 00000000..6392def5 --- /dev/null +++ b/crates/pdf/src/export.rs @@ -0,0 +1,662 @@ +//! Document → PDF (krilla). Mirrors the tree walk of `drawcraft-render`. + +use std::collections::HashMap; + +use drawcraft_color::{BlendMode, Color, GradientKind, Paint}; +use drawcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use drawcraft_geom::{Affine, BezPath, FillRule, Rect}; +use krilla::color::{cmyk, luma, rgb}; +use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion}; +use krilla::geom::{Path, PathBuilder, Size, Transform}; +use krilla::image::Image; +use krilla::metadata::Metadata; +use krilla::num::NormalizedF32; +use krilla::page::PageSettings; +use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; +use krilla::surface::Surface; +use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2}; + +use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; + +/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). +pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { + export_with_report(doc, opts).map(|r| r.bytes) +} + +/// Like [`export`], also returning warnings about approximated or dropped features. +pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + if doc.artboards.is_empty() { + return Err(PdfError::NoArtboards); + } + let indices: Vec = match &opts.artboards { + Some(v) => v.clone(), + None => (0..doc.artboards.len()).collect(), + }; + if indices.is_empty() { + return Err(PdfError::NoArtboards); + } + if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) { + return Err(PdfError::BadArtboard(*bad)); + } + + let (version, archival) = match opts.compatibility { + Compatibility::Pdf14 => (PdfVersion::Pdf14, None), + Compatibility::Pdf15 => (PdfVersion::Pdf15, None), + Compatibility::Pdf16 => (PdfVersion::Pdf16, None), + Compatibility::Pdf17 => (PdfVersion::Pdf17, None), + Compatibility::Pdf20 => (PdfVersion::Pdf20, None), + Compatibility::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)), + Compatibility::PdfX4 => return Err(PdfError::Unsupported("PDF/X-4 output is not supported by the PDF writer yet".into())), + }; + let mut cb = ConfigurationBuilder::new().with_version(version); + if let Some(a) = archival { + cb = cb.with_archival_validator(a); + } + let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; + + let mut pdf = krilla::Document::new_with(settings); + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let mut meta = Metadata::new().creator("DrawCraft".into()).producer("DrawCraft".into()); + if !title.is_empty() { + meta = meta.title(title); + } + if let Some(t) = opts.created.or_else(now_unix) { + meta = meta.creation_date(date_time(t)); + } + pdf.set_metadata(meta); + + let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() }; + for i in indices { + let ab = &doc.artboards[i]; + let r = ab.rect; + let size = Size::from_wh(r.width().max(1.0) as f32, r.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?; + let mut page = pdf.start_page_with(PageSettings::new(size)); + let mut s = page.surface(); + // krilla's page space is y-down with the origin at the top-left corner, like ours. + s.push_transform(&xf(Affine::translate((-r.x0, -r.y0)))); + for layer in &doc.layers { + ex.node(&mut s, layer, r, false); + } + s.pop(); + s.finish(); + page.finish(); + } + let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let mut warnings = ex.warnings; + warnings.dedup(); + Ok(ExportReport { bytes, warnings }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn now_unix() -> Option { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64) +} +#[cfg(target_arch = "wasm32")] +fn now_unix() -> Option { + None +} + +/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian). +fn date_time(t: i64) -> krilla::metadata::DateTime { + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16) + .month(month as u8) + .day(day as u8) + .hour((secs / 3600) as u8) + .minute((secs / 60 % 60) as u8) + .second((secs % 60) as u8) + .utc_offset_hour(0) + .utc_offset_minute(0) +} + +struct Exporter<'a> { + doc: &'a Document, + warnings: Vec, + images: HashMap>, +} + +fn xf(a: Affine) -> Transform { + let c = a.as_coeffs(); + Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) +} + +fn to_path(bp: &BezPath) -> Option { + let mut pb = PathBuilder::new(); + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32), + PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32), + PathEl::ClosePath => pb.close(), + } + } + pb.finish() +} + +fn norm(v: f32) -> NormalizedF32 { + NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE) +} + +fn rule(r: FillRule) -> krilla::paint::FillRule { + match r { + FillRule::NonZero => krilla::paint::FillRule::NonZero, + FillRule::EvenOdd => krilla::paint::FillRule::EvenOdd, + } +} + +fn blend(b: BlendMode) -> krilla::blend::BlendMode { + use krilla::blend::BlendMode as K; + match b { + BlendMode::Normal => K::Normal, + BlendMode::Darken => K::Darken, + BlendMode::Multiply => K::Multiply, + BlendMode::ColorBurn => K::ColorBurn, + BlendMode::Lighten => K::Lighten, + BlendMode::Screen => K::Screen, + BlendMode::ColorDodge => K::ColorDodge, + BlendMode::Overlay => K::Overlay, + BlendMode::SoftLight => K::SoftLight, + BlendMode::HardLight => K::HardLight, + BlendMode::Difference => K::Difference, + BlendMode::Exclusion => K::Exclusion, + BlendMode::Hue => K::Hue, + BlendMode::Saturation => K::Saturation, + BlendMode::Color => K::Color, + BlendMode::Luminosity => K::Luminosity, + } +} + +fn q(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn color(c: &Color) -> krilla::color::Color { + match *c { + Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(), + Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(), + // DrawCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness. + Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(), + } +} + +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +impl Exporter<'_> { + fn warn(&mut self, w: impl Into) { + let w = w.into(); + if !self.warnings.contains(&w) { + self.warnings.push(w); + } + } + + /// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer). + fn paint(&mut self, p: &Paint, bounds: Rect) -> Option { + match p { + Paint::None => None, + Paint::Solid { color: c, .. } => Some(color(c).into()), + Paint::Gradient(g) => { + let geom = g.resolve(bounds); + let mut stops: Vec = Vec::new(); + let mut last = 0.0f32; + for (o, c, a) in g.gradient.expanded_stops() { + let o = o.clamp(last, 1.0); + last = o; + stops.push(Stop { offset: norm(o), color: color(&c), opacity: norm(a) }); + } + if stops.is_empty() { + return None; + } + match g.gradient.kind { + GradientKind::Linear => { + let (s, mut e) = (geom.start, geom.end); + if s.distance(e) < 1e-9 { + e = s + Vec2::new(1.0, 0.0); + } + Some( + LinearGradient { + x1: s.x as f32, + y1: s.y as f32, + x2: e.x as f32, + y2: e.y as f32, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into(), + ) + } + GradientKind::Radial => { + let r = geom.length().max(1e-6) as f32; + let angle = (geom.end - geom.start).atan2(); + let t = Affine::translate(geom.start.to_vec2()) + * Affine::rotate(angle) + * Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3)) + * Affine::rotate(-angle) + * Affine::translate(-geom.start.to_vec2()); + let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); + Some( + RadialGradient { + fx: cx, + fy: cy, + fr: 0.0, + cx, + cy, + cr: r, + transform: xf(t), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into(), + ) + } + GradientKind::Freeform => { + self.warn("freeform gradients are exported as their average colour"); + let n = g.gradient.stops.len().max(1) as f32; + let mut acc = [0.0f32; 3]; + for s in &g.gradient.stops { + let c = s.color.to_rgb(); + for i in 0..3 { + acc[i] += c[i] / n; + } + } + Some(color(&Color::rgb(acc[0], acc[1], acc[2])).into()) + } + } + } + Paint::Pattern { .. } => { + self.warn("pattern fills are exported as mid-grey"); + Some(rgb::Color::new(128, 128, 128).into()) + } + } + } + + fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { + if !force && !n.visible { + return; + } + if let NodeKind::Layer { template: true, .. } = n.kind { + return; + } + match n.visual_bounds() { + Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, + None if !n.is_container() => return, + _ => {} + } + if !n.appearance.effects.is_empty() { + self.warn("live effects are not exported to PDF yet"); + } + let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); + let mut pushes = 0; + if n.blend != BlendMode::Normal { + s.push_blend_mode(blend(n.blend)); + pushes += 1; + } + if n.opacity < 1.0 { + s.push_opacity(norm(n.opacity)); + pushes += 1; + } else if container && (n.isolate || n.blend != BlendMode::Normal) { + s.push_isolated(); + pushes += 1; + } + if n.knockout { + self.warn("knockout groups are exported as normal groups"); + } + match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children { + self.node(s, c, page, false); + } + } + NodeKind::Group { children, clip: true } => { + let clip = children.first().and_then(|c| match &c.kind { + NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)), + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for ch in children { + if let Some(p) = ch.path_data() { + bp.extend(p.to_bezpath()); + } + } + Some((bp, *rule)) + } + _ => None, + }); + match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) { + Some((p, r)) => { + s.push_clip_path(&p, &rule(r)); + for c in children.iter().skip(1) { + self.node(s, c, page, false); + } + s.pop(); + } + None => { + for c in children { + self.node(s, c, page, false); + } + } + } + } + NodeKind::Path { path, rule, guide, .. } => { + if !*guide { + self.shape(s, n, &path.to_bezpath(), *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + bp.extend(p.to_bezpath()); + } + } + self.shape(s, n, &bp, *rule); + } + NodeKind::Text(t) => self.text(s, n, t), + NodeKind::Image(im) => self.image(s, im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.node(s, &art, page, true); + } + } + } + for _ in 0..pushes { + s.pop(); + } + } + + fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) { + let Some(path) = to_path(bp) else { return }; + let bounds = bp.bounding_box(); + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(fl) => { + if !fl.visible || fl.paint.is_none() { + continue; + } + if !fl.effects.is_empty() { + self.warn("live effects are not exported to PDF yet"); + } + let Some(paint) = self.paint(&fl.paint, bounds) else { continue }; + let bl = fl.blend != BlendMode::Normal; + if bl { + s.push_blend_mode(blend(fl.blend)); + } + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) })); + s.draw_path(&path); + if bl { + s.pop(); + } + } + AppearanceItem::Stroke(st) => { + if !st.visible || st.paint.is_none() || st.width <= 0.0 { + continue; + } + self.stroke(s, bp, &path, r, st, bounds); + } + } + } + s.set_fill(None); + s.set_stroke(None); + } + + fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) { + if st.profile.is_some() || st.brush.is_some() { + self.warn("variable-width profiles and brushes are exported as uniform strokes"); + } + let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return }; + let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)); + let mut pushes = 0; + if st.blend != BlendMode::Normal { + s.push_blend_mode(blend(st.blend)); + pushes += 1; + } + let width = match st.align { + StrokeAlign::Center => st.width, + _ if closed => st.width * 2.0, + _ => st.width, + }; + match st.align { + StrokeAlign::Inside if closed => { + s.push_clip_path(path, &rule(r)); + pushes += 1; + } + StrokeAlign::Outside if closed => { + // Clip to everything outside the path: a big frame plus the path, even-odd. + let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0); + let mut outside = big.to_path(0.1); + outside.extend(bp.iter()); + if let Some(p) = to_path(&outside) { + s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); + pushes += 1; + } + } + _ => {} + } + let dash = st.dash.as_ref().filter(|d| d.pattern.iter().any(|v| *v > 0.0)).map(|d| { + let mut pat: Vec = d.pattern.iter().map(|v| v.max(0.0) as f32).collect(); + if pat.len() % 2 == 1 { + pat.extend(pat.clone()); + } + StrokeDash { array: pat, offset: d.offset as f32 } + }); + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: paint.clone(), + width: width as f32, + miter_limit: st.miter_limit.max(1.0) as f32, + line_cap: match st.cap { + LineCap::Butt => krilla::paint::LineCap::Butt, + LineCap::Round => krilla::paint::LineCap::Round, + LineCap::Square => krilla::paint::LineCap::Square, + }, + line_join: match st.join { + LineJoin::Miter => krilla::paint::LineJoin::Miter, + LineJoin::Round => krilla::paint::LineJoin::Round, + LineJoin::Bevel => krilla::paint::LineJoin::Bevel, + }, + opacity: norm(st.opacity), + dash, + })); + s.draw_path(path); + s.set_stroke(None); + // Pop the alignment clip before drawing arrowheads. + if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 { + s.pop(); + pushes -= 1; + } + if st.start_arrow.is_some() || st.end_arrow.is_some() { + for head in arrowheads(bp, st) { + if let Some(p) = to_path(&head) { + s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&p); + } + } + s.set_fill(None); + } + for _ in 0..pushes { + s.pop(); + } + } + + fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { + let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let tb = t.xf.transform_rect_bbox(layout.bounds); + s.push_transform(&xf(t.xf)); + for (i, run) in t.runs.iter().enumerate() { + let mut bp = BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + let Some(path) = to_path(&bp) else { continue }; + if let Some(paint) = self.paint(&run.style.fill, layout.bounds) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + } + if run.style.stroke_width > 0.0 + && let Some(paint) = self.paint(&run.style.stroke, layout.bounds) + { + s.set_fill(None); + s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() })); + s.draw_path(&path); + } + } + s.set_fill(None); + s.set_stroke(None); + s.pop(); + if !n.appearance.items.is_empty() { + let mut all = layout.to_bezpath(); + all.apply_affine(t.xf); + let Some(path) = to_path(&all) else { return }; + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(fl) if fl.visible => { + if let Some(paint) = self.paint(&fl.paint, tb) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + } + } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { + if let Some(paint) = self.paint(&st.paint, tb) { + s.set_fill(None); + s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); + s.draw_path(&path); + } + } + _ => {} + } + } + s.set_fill(None); + s.set_stroke(None); + } + } + + fn load_image(&mut self, key: &str) -> Option { + if let Some(i) = self.images.get(key) { + return i.clone(); + } + let img = self.doc.images.get(key).and_then(|blob| { + let bytes = blob.bytes.as_ref().clone(); + let direct = match blob.mime.as_str() { + "image/png" => Image::from_png(bytes.clone().into(), true).ok(), + "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), true).ok(), + "image/gif" => Image::from_gif(bytes.clone().into(), true).ok(), + "image/webp" => Image::from_webp(bytes.clone().into(), true).ok(), + _ => None, + }; + direct.or_else(|| { + let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); + let (w, h) = rgba.dimensions(); + Some(Image::from_rgba8(rgba.into_raw(), w, h)) + }) + }); + if img.is_none() { + self.warn(format!("image '{key}' could not be decoded and was skipped")); + } + self.images.insert(key.to_string(), img.clone()); + img + } + + fn image(&mut self, s: &mut Surface, im: &drawcraft_doc::ImageObject) { + let Some(img) = self.load_image(&im.key) else { return }; + let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; + s.push_transform(&xf(im.xf)); + s.draw_image(img, size); + s.pop(); + } +} + +/// Arrowhead outlines for a stroke (same geometry as the renderer). +fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec { + let segs: Vec = bp.segments().collect(); + let mut out = Vec::new(); + let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out }; + if let Some(a) = st.start_arrow { + out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0)); + } + if let Some(a) = st.end_arrow { + out.push(head(a, last.eval(1.0), tangent(last, 1.0), st.width * st.arrow_scale.1 / 100.0)); + } + out +} + +fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 { + let d = match s { + kurbo::PathSeg::Line(l) => l.p1 - l.p0, + kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + kurbo::PathSeg::Cubic(c) => { + let v = c.deriv().eval(t).to_vec2(); + if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v } + } + }; + let l = d.hypot(); + if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l } +} + +fn head(kind: Arrowhead, tip: kurbo::Point, dir: Vec2, w: f64) -> BezPath { + let w = w.max(0.25); + let n = Vec2::new(-dir.y, dir.x); + let len = 4.0 * w; + let half = 2.0 * w; + let base = tip - dir * len; + let mut p = BezPath::new(); + match kind { + Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => { + p.move_to(tip); + p.line_to(base + n * half); + if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) { + p.line_to(tip - dir * (len * 0.7)); + } + p.line_to(base - n * half); + p.close_path(); + } + Arrowhead::Circle | Arrowhead::CircleOpen => { + p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01); + } + Arrowhead::Square | Arrowhead::SquareOpen => { + let c = tip - dir * half; + p.move_to(c + dir * half + n * half); + p.line_to(c - dir * half + n * half); + p.line_to(c - dir * half - n * half); + p.line_to(c + dir * half - n * half); + p.close_path(); + } + Arrowhead::Diamond => { + let c = tip - dir * half; + p.move_to(tip); + p.line_to(c + n * half); + p.line_to(c - dir * half); + p.line_to(c - n * half); + p.close_path(); + } + Arrowhead::Bar => { + let t = w * 0.75; + p.move_to(tip + n * half); + p.line_to(tip + n * half - dir * t); + p.line_to(tip - n * half - dir * t); + p.line_to(tip - n * half); + p.close_path(); + } + } + p +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs new file mode 100644 index 00000000..8bad725b --- /dev/null +++ b/crates/pdf/src/import.rs @@ -0,0 +1,650 @@ +//! PDF → Document (hayro-interpret device that builds a DrawCraft node tree). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use drawcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use drawcraft_doc::{ + Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, + StrokeLayer, +}; +use drawcraft_geom::{FillRule, PathData}; +use hayro_interpret::font::Glyph; +use hayro_interpret::pattern::Pattern; +use hayro_interpret::shading::ShadingType; +use hayro_interpret::{ + ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode, + SoftMask, StrokeProps, interpret_page, +}; +use hayro_syntax::Pdf; +use hayro_syntax::object::Name; +use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; + +use crate::{ImportOptions, ImportReport, PdfError}; + +/// Import a PDF (or PDF-compatible `.ai`) with default options. +pub fn import(bytes: &[u8]) -> Result { + import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) +} + +/// Import a PDF, returning the document plus warnings about content that was approximated or skipped. +pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { + let pdf = Pdf::new(bytes.to_vec()).map_err(|e| PdfError::Parse(format!("{e:?}")))?; + let pages = pdf.pages(); + let n = opts.max_pages.map_or(pages.len(), |m| m.min(pages.len())); + if n == 0 { + return Err(PdfError::NoPages); + } + + let sink: Arc>> = Arc::default(); + let sink2 = sink.clone(); + let settings = InterpreterSettings { + warning_sink: Arc::new(move |w| { + let msg = match w { + InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped", + InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", + }; + if let Ok(mut v) = sink2.lock() + && !v.iter().any(|x| x == msg) + { + v.push(msg.to_string()); + } + }), + render_annotations: false, + ..Default::default() + }; + + let mut doc = Document::new(1.0, 1.0); + doc.title = "Imported PDF".into(); + doc.artboards.clear(); + doc.layers.clear(); + let mut b = Builder::new(doc.peek_next_id()); + let cache = InterpreterCache::new(); + let mut x = 0.0; + for (i, page) in pages.iter().take(n).enumerate() { + let (w, h) = page.render_dimensions(); + let (w, h) = (w as f64, h as f64); + let ab = Rect::new(x, 0.0, x + w, h); + x += w + opts.artboard_gap; + doc.artboards.push(Artboard { + id: i as u32 + 1, + name: format!("Artboard {}", i + 1), + rect: ab, + show_center_mark: false, + show_cross_hairs: false, + }); + let init = Affine::translate((ab.x0, ab.y0)) * Affine::new(page.initial_transform(true).as_coeffs()); + let mut ctx = Context::new(init, ab, &cache, pdf.xref(), settings.clone()); + b.page = ab; + b.begin_page(); + interpret_page(page, &mut ctx, &mut b); + let children = b.end_page(); + let mut layer = Node::layer(b.id(), &format!("Page {}", i + 1), LayerColor::Preset((i % 27) as u8)); + if let NodeKind::Layer { children: c, .. } = &mut layer.kind { + *c = children; + } + doc.layers.push(Arc::new(layer)); + } + for (k, blob) in b.images.drain() { + doc.images.insert(k, blob); + } + doc.fix_next_id(); + + let mut warnings = b.warnings; + if let Ok(v) = sink.lock() { + for w in v.iter() { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + } + Ok(ImportReport { document: doc, warnings }) +} + +/// Decoded RGBA pixels, width, height and hayro's scale factors. +type Decoded = (Vec, u32, u32, (f32, f32)); + +enum FrameKind { + Root, + /// A clip group whose clip path node is stored here. + Clip(Box), + /// A clip that was redundant (covers the page); children go straight to the parent. + Skip, + Group { + opacity: f32, + blend: BlendMode, + masked: bool, + }, +} + +struct Frame { + kind: FrameKind, + children: Vec>, +} + +/// Glyphs drawn consecutively with the same paint, merged into one path. +struct GlyphRun { + path: BezPath, + paint: Paint, + opacity: f32, + stroke: Option, + scale: f64, +} + +struct Builder { + next: u64, + stack: Vec, + blend: BlendMode, + page: Rect, + glyphs: Option, + images: HashMap, + image_keys: HashMap, + warnings: Vec, +} + +fn blend(b: hayro_interpret::BlendMode) -> BlendMode { + use hayro_interpret::BlendMode as H; + match b { + H::Normal => BlendMode::Normal, + H::Multiply => BlendMode::Multiply, + H::Screen => BlendMode::Screen, + H::Overlay => BlendMode::Overlay, + H::Darken => BlendMode::Darken, + H::Lighten => BlendMode::Lighten, + H::ColorDodge => BlendMode::ColorDodge, + H::ColorBurn => BlendMode::ColorBurn, + H::HardLight => BlendMode::HardLight, + H::SoftLight => BlendMode::SoftLight, + H::Difference => BlendMode::Difference, + H::Exclusion => BlendMode::Exclusion, + H::Hue => BlendMode::Hue, + H::Saturation => BlendMode::Saturation, + H::Color => BlendMode::Color, + H::Luminosity => BlendMode::Luminosity, + } +} + +fn fill_rule(r: hayro_interpret::FillRule) -> FillRule { + match r { + hayro_interpret::FillRule::NonZero => FillRule::NonZero, + hayro_interpret::FillRule::EvenOdd => FillRule::EvenOdd, + } +} + +fn mean_scale(a: Affine) -> f64 { + a.determinant().abs().sqrt() +} + +fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer { + let mut st = StrokeLayer::new(paint, p.line_width as f64 * scale); + st.opacity = opacity; + st.cap = match p.line_cap { + kurbo::Cap::Butt => LineCap::Butt, + kurbo::Cap::Round => LineCap::Round, + kurbo::Cap::Square => LineCap::Square, + }; + st.join = match p.line_join { + kurbo::Join::Miter => LineJoin::Miter, + kurbo::Join::Round => LineJoin::Round, + kurbo::Join::Bevel => LineJoin::Bevel, + }; + st.miter_limit = p.miter_limit as f64; + if p.dash_array.iter().any(|v| *v > 0.0) { + st.dash = Some(Dash { + pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), + offset: p.dash_offset as f64 * scale, + align_corners: false, + }); + } + st +} + +fn round3(v: f32) -> f32 { + (v * 1000.0).round() / 1000.0 +} + +impl Builder { + fn new(next: u64) -> Self { + Self { + next, + stack: vec![], + blend: BlendMode::Normal, + page: Rect::ZERO, + glyphs: None, + images: HashMap::new(), + image_keys: HashMap::new(), + warnings: vec![], + } + } + + fn id(&mut self) -> NodeId { + let id = NodeId(self.next); + self.next += 1; + id + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn begin_page(&mut self) { + self.stack = vec![Frame { kind: FrameKind::Root, children: vec![] }]; + self.blend = BlendMode::Normal; + self.glyphs = None; + } + + fn end_page(&mut self) -> Vec> { + self.flush_glyphs(); + while self.stack.len() > 1 { + self.pop_frame(); + } + self.stack.pop().map(|f| f.children).unwrap_or_default() + } + + fn push_node(&mut self, mut n: Node) { + if self.blend != BlendMode::Normal && !n.is_container() { + n.blend = self.blend; + } + if let Some(f) = self.stack.last_mut() { + f.children.push(Arc::new(n)); + } + } + + fn pop_frame(&mut self) { + if self.stack.len() <= 1 { + return; + } + let f = self.stack.pop().expect("frame"); + let node = match f.kind { + FrameKind::Root => None, + FrameKind::Skip => { + if let Some(p) = self.stack.last_mut() { + p.children.extend(f.children); + } + None + } + FrameKind::Clip(clip) => { + if f.children.is_empty() { + None + } else { + let mut ch = vec![Arc::new(*clip)]; + ch.extend(f.children); + Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true })) + } + } + FrameKind::Group { opacity, blend, masked } => { + if f.children.is_empty() { + None + } else if opacity >= 0.999 && blend == BlendMode::Normal && !masked { + if let Some(p) = self.stack.last_mut() { + p.children.extend(f.children); + } + None + } else if f.children.len() == 1 && !f.children[0].is_container() && !masked { + // A single object in a group: fold opacity/blend into the object itself. + let mut only = (*f.children[0]).clone(); + only.opacity *= opacity; + if blend != BlendMode::Normal { + only.blend = blend; + } + Some(only) + } else { + let mut g = Node::group(self.id(), f.children); + g.opacity = opacity; + g.blend = blend; + Some(g) + } + } + }; + if let Some(n) = node + && let Some(p) = self.stack.last_mut() + { + p.children.push(Arc::new(n)); + } + } + + fn flush_glyphs(&mut self) { + let Some(run) = self.glyphs.take() else { return }; + if run.path.elements().is_empty() { + return; + } + let mut ap = Appearance::default(); + match &run.stroke { + None => { + let mut f = FillLayer::new(run.paint); + f.opacity = run.opacity; + ap.items.push(AppearanceItem::Fill(f)); + } + Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))), + } + let id = self.id(); + let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); + n.name = Some("".into()); + self.push_node(n); + } + + /// Convert a hayro paint to a DrawCraft paint and opacity. `bbox` is the painted area in + /// document coordinates (for patterns). + fn paint(&mut self, p: &hayro_interpret::Paint<'_>) -> (Paint, f32) { + match p { + hayro_interpret::Paint::Color(c) => { + let [r, g, b, a] = c.to_rgba().components(); + (Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a) + } + hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() { + Pattern::Shading(sp) => match shading_gradient(sp) { + Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity), + None => { + self.warn("mesh/function shadings are imported as a flat colour"); + (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity) + } + }, + Pattern::Tiling(_) => { + self.warn("tiling patterns are imported as a flat grey"); + (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0) + } + }, + } + } + + fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { + let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { + v + } else { + let Some((blob, w, h)) = make() else { + self.warn("an image could not be decoded and was skipped"); + return; + }; + let k = format!("pdf-image-{}", self.image_keys.len() + 1); + self.images.insert(k.clone(), blob); + self.image_keys.insert(key, (k.clone(), w, h)); + (k, w, h) + }; + let id = self.id(); + self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None }))); + } +} + +/// Axial/radial shading → gradient paint (stops sampled from the shading function). +fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option { + let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else { + return None; + }; + let m = sp.matrix; + let cs = &sp.shading.color_space; + let sample = |t: f32| -> Option<(Color, f32)> { + let x = domain[0] + (domain[1] - domain[0]) * t; + let v = function.eval(&smallvec::smallvec![x])?; + let [r, g, b, a] = cs.to_rgba(&v, 1.0, false).components(); + Some((Color::rgb(round3(r), round3(g), round3(b)), a)) + }; + // Sample densely, then drop samples that linear interpolation reproduces. + const N: usize = 64; + let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); + for i in 0..=N { + let t = i as f32 / N as f32; + let (c, _) = sample(t)?; + pts.push((t, c.to_rgb())); + } + let mut keep = vec![0usize]; + for i in 1..N { + let a = pts[*keep.last().expect("non-empty")]; + let b = pts[i + 1]; + let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6); + let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max); + if off > 1.5 / 255.0 { + keep.push(i); + } + } + keep.push(N); + let stop_at = |t: f32, offset: f32| -> GradientStop { + let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); + GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 } + }; + let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); + let (kind, geom, stops) = if *axial { + let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0 }; + (GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::>()) + } else { + let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6)); + let centre = c(coords[3], coords[4]); + let k = m.as_coeffs(); + let ex = Vec2::new(k[0], k[1]) * r1 as f64; + let ey = Vec2::new(k[2], k[3]) * r1 as f64; + let aspect = if ex.hypot() > 1e-9 { ey.hypot() / ex.hypot() } else { 1.0 }; + let geom = GradientGeom { start: centre, end: centre + ex, aspect }; + // Offsets are relative to the outer radius; an inner radius shifts them outwards. + let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect(); + (GradientKind::Radial, geom, stops) + }; + let mut g = GradientPaint::new(Gradient { kind, stops }); + g.geom = Some(geom); + Some(g) +} + +fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { + let img = image::RgbaImage::from_raw(w, h, rgba)?; + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + +fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { + if a.width == w && a.height == h { + return a.data.clone(); + } + match image::GrayImage::from_raw(a.width, a.height, a.data.clone()) { + Some(g) => image::imageops::resize(&g, w, h, image::imageops::FilterType::Triangle).into_raw(), + None => vec![255; (w * h) as usize], + } +} + +impl<'a> Device<'a> for Builder { + fn set_soft_mask(&mut self, mask: Option>) { + if mask.is_some() { + self.warn("soft masks are not imported (content drawn unmasked)"); + } + } + + fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) { + self.blend = blend(blend_mode); + } + + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) { + self.flush_glyphs(); + let mut bp = path.clone(); + bp.apply_affine(transform); + if bp.elements().is_empty() { + return; + } + let pd = PathData::from_bezpath(&bp); + let (paint, opacity) = self.paint(paint); + match draw_mode { + PathDrawMode::Fill(rule) => { + let mut f = FillLayer::new(paint); + f.opacity = opacity; + let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] }); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = fill_rule(*rule); + } + self.push_node(n); + } + PathDrawMode::Stroke(props) => { + let st = stroke_layer(paint, opacity, props, mean_scale(transform)); + // Fill-then-stroke of the same path (the `B` operator) becomes one object. + let blend = self.blend; + if let Some(f) = self.stack.last_mut() + && let Some(last) = f.children.last_mut() + && last.blend == blend + && last.path_data() == Some(&pd) + && last.appearance.stroke().is_none() + { + Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st)); + return; + } + let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + self.push_node(n); + } + } + } + + fn push_clip_path(&mut self, clip_path: &ClipPath) { + self.flush_glyphs(); + let bb = clip_path.path.bounding_box(); + // Clips that contain the whole page do nothing visible; don't create groups for them. + let redundant = clip_path.path.elements().len() <= 6 + && bb.x0 <= self.page.x0 + 0.01 + && bb.y0 <= self.page.y0 + 0.01 + && bb.x1 >= self.page.x1 - 0.01 + && bb.y1 >= self.page.y1 - 0.01 + && self.page.area() > 0.0; + let kind = if redundant { + FrameKind::Skip + } else { + let mut clip = Node::new( + self.id(), + NodeKind::Path { + path: PathData::from_bezpath(&clip_path.path), + rule: fill_rule(clip_path.fill), + live: None, + clipping: true, + guide: false, + }, + ); + clip.name = Some("".into()); + FrameKind::Clip(Box::new(clip)) + }; + self.stack.push(Frame { kind, children: vec![] }); + } + + fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: hayro_interpret::BlendMode) { + self.flush_glyphs(); + let masked = mask.is_some(); + if masked { + self.warn("soft masks are not imported (content drawn unmasked)"); + } + self.stack.push(Frame { kind: FrameKind::Group { opacity, blend: blend(blend_mode), masked }, children: vec![] }); + // Blend mode applies to the group as a whole, not to its children. + self.blend = BlendMode::Normal; + } + + fn draw_glyph( + &mut self, + glyph: &Glyph<'a>, + transform: Affine, + glyph_transform: Affine, + paint: &hayro_interpret::Paint<'a>, + draw_mode: &GlyphDrawMode, + ) { + let stroke = match draw_mode { + GlyphDrawMode::Invisible => return, + GlyphDrawMode::Fill => None, + GlyphDrawMode::Stroke(p) => Some(p.clone()), + }; + match glyph { + Glyph::Outline(o) => { + let mut bp = o.outline(); + bp.apply_affine(transform * glyph_transform); + let (paint, opacity) = self.paint(paint); + let scale = mean_scale(transform); + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == paint + && r.opacity == opacity + && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) + && (r.scale - scale).abs() < 1e-9 + }); + if !same { + self.flush_glyphs(); + self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale }); + } + if let Some(r) = &mut self.glyphs { + r.path.extend(bp.iter()); + } + self.warn("text was converted to outlines"); + } + Glyph::Type3(t3) => { + self.flush_glyphs(); + t3.interpret(self, transform, glyph_transform, paint); + } + } + } + + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + self.flush_glyphs(); + match image { + Image::Raster(r) => { + let key = hayro_interpret::CacheKey::cache_key(&r); + // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. + let st = r.stream(); + let dict = st.dict(); + let filters = st.filters(); + let cs_ok = dict.get::>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray")); + let jpeg = filters.len() == 1 + && matches!(filters[0], hayro_syntax::Filter::DctDecode) + && cs_ok + && !dict.contains_key(b"SMask") + && !dict.contains_key(b"Mask") + && !dict.contains_key(b"Decode"); + if jpeg { + let (w, h) = (r.width(), r.height()); + let bytes = st.raw_data().to_vec(); + self.add_image(key, || Some((ImageBlob { mime: "image/jpeg".into(), bytes: Arc::new(bytes) }, w, h)), transform); + return; + } + let mut decoded: Option = None; + r.with_rgba( + |img, alpha| { + let (w, h, sf) = (img.width(), img.height(), img.scale_factors()); + let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]); + let rgba: Vec = match img { + ImageData::Rgb(d) => d.data.chunks_exact(3).zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(), + ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(), + }; + decoded = Some((rgba, w, h, sf)); + }, + None, + ); + let Some((rgba, w, h, sf)) = decoded else { + self.warn("an image could not be decoded and was skipped"); + return; + }; + let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); + self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), xf); + } + Image::Stencil(s) => { + let key = hayro_interpret::CacheKey::cache_key(&s); + let mut decoded: Option = None; + s.with_stencil( + |luma, paint| { + let rgb = match paint { + hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(), + _ => [0, 0, 0, 255], + }; + let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect(); + decoded = Some((rgba, luma.width, luma.height, luma.scale_factors)); + }, + None, + ); + let Some((rgba, w, h, sf)) = decoded else { return }; + let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); + self.add_image( + key ^ 0x5_7e9c, + || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), + xf, + ); + } + } + } + + fn pop_clip_path(&mut self) { + self.flush_glyphs(); + self.pop_frame(); + } + + fn pop_transparency_group(&mut self) { + self.flush_glyphs(); + self.pop_frame(); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9476b2ef..25ca6f4e 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1,2 +1,107 @@ -//! DrawCraft PDF export and import +//! DrawCraft PDF export and import. +//! +//! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with +//! caps/joins/miter/dashes, non-zero/even-odd), opacity and blend modes (transparency groups), +//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph +//! paths. Hidden objects, guides and template layers are skipped. +//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a +//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, +//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others +//! re-encoded as PNG) and text as glyph outlines. #![forbid(unsafe_code)] + +mod export; +mod import; + +pub use export::{export, export_with_report}; +pub use import::{import, import_with_report}; + +use drawcraft_doc::Document; + +/// PDF standard / version to target. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Compatibility { + /// Acrobat 5 (PDF 1.4). + Pdf14, + /// Acrobat 6 (PDF 1.5). + Pdf15, + /// Acrobat 7 (PDF 1.6). + Pdf16, + /// Acrobat 8+ (PDF 1.7). The default. + #[default] + Pdf17, + /// PDF 2.0. + Pdf20, + /// PDF/A-2b (archival; validated by krilla). + PdfA2b, + /// PDF/X-4 — not supported by the writer yet; export returns [`PdfError::Unsupported`]. + PdfX4, +} + +/// Export options. +#[derive(Clone, Debug)] +pub struct PdfOptions { + /// 0-based artboard indices to export, in page order; `None` = all artboards. + pub artboards: Option>, + pub compatibility: Compatibility, + /// Compress content streams (Flate). + pub compress: bool, + /// Document title for the metadata; `None` = the document's title. + pub title: Option, + /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date. + pub created: Option, +} + +impl Default for PdfOptions { + fn default() -> Self { + Self { artboards: None, compatibility: Compatibility::Pdf17, compress: true, title: None, created: None } + } +} + +/// Import options. +#[derive(Clone, Debug)] +pub struct ImportOptions { + /// Import at most this many pages (from the first); `None` = all pages. + pub max_pages: Option, + /// Horizontal gap in points between the artboards created for consecutive pages. + pub artboard_gap: f64, +} + +impl Default for ImportOptions { + fn default() -> Self { + Self { max_pages: None, artboard_gap: 36.0 } + } +} + +/// Result of an import with non-fatal warnings (unsupported features, skipped content). +#[derive(Clone, Debug)] +pub struct ImportReport { + pub document: Document, + pub warnings: Vec, +} + +/// Result of an export with non-fatal warnings (features approximated or dropped). +#[derive(Clone, Debug)] +pub struct ExportReport { + pub bytes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PdfError { + #[error("the document has no artboards")] + NoArtboards, + #[error("artboard {0} does not exist")] + BadArtboard(usize), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("PDF writer error: {0}")] + Write(String), + #[error("cannot read PDF: {0}")] + Parse(String), + #[error("the PDF has no pages")] + NoPages, +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs new file mode 100644 index 00000000..cd72010f --- /dev/null +++ b/crates/pdf/src/tests.rs @@ -0,0 +1,534 @@ +use std::sync::Arc; + +use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; +use drawcraft_doc::{ + Appearance, AppearanceItem, Artboard, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject, +}; +use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; + +use crate::*; + +fn doc(w: f64, h: f64) -> Document { + Document::new(w, h) +} + +fn add(d: &mut Document, mut n: Node) -> NodeId { + n.id = d.alloc_id(); + let layer = d.default_layer().expect("layer"); + let len = d.children(Some(layer)).map_or(0, |c| c.len()); + d.insert(Some(layer), len, n).expect("insert") +} + +fn rect_node(r: Rect, fill: Color) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)) +} + +fn leaves(d: &Document) -> Vec<&Node> { + let mut out = vec![]; + d.walk(|n| { + if !n.is_container() && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) { + out.push(n); + } + }); + out +} + +fn close(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol +} + +fn roundtrip(d: &Document) -> Document { + let bytes = export(d, &PdfOptions::default()).expect("export"); + import(&bytes).expect("import") +} + +fn uncompressed(d: &Document) -> String { + let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).expect("export"); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn page_count(bytes: &[u8]) -> usize { + hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse").pages().len() +} + +#[test] +fn export_is_valid_pdf() { + let mut d = doc(200.0, 100.0); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::rgb(1.0, 0.0, 0.0))); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.7")); + let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).into_owned(); + assert!(tail.contains("startxref") && tail.contains("%%EOF"), "{tail}"); + assert!(String::from_utf8_lossy(&bytes).contains("xref")); + assert_eq!(page_count(&bytes), 1); +} + +#[test] +fn metadata_creator_and_title() { + let mut d = doc(100.0, 100.0); + d.title = "Poster".into(); + let s = uncompressed(&d); + assert!(s.contains("DrawCraft")); + assert!(s.contains("Poster")); +} + +#[test] +fn one_page_per_artboard_with_sizes() { + let mut d = doc(200.0, 100.0); + d.artboards.push(Artboard { + id: 2, + name: "B".into(), + rect: Rect::new(300.0, 0.0, 400.0, 400.0), + show_center_mark: false, + show_cross_hairs: false, + }); + d.artboards.push(Artboard { + id: 3, + name: "C".into(), + rect: Rect::new(0.0, 500.0, 612.0, 1292.0), + show_center_mark: false, + show_cross_hairs: false, + }); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect(); + assert_eq!(sizes, vec![(200.0, 100.0), (100.0, 400.0), (612.0, 792.0)]); +} + +#[test] +fn artboard_range_and_bad_index() { + let mut d = doc(200.0, 100.0); + d.artboards.push(Artboard { + id: 2, + name: "B".into(), + rect: Rect::new(300.0, 0.0, 400.0, 400.0), + show_center_mark: false, + show_cross_hairs: false, + }); + let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + assert_eq!(pdf.pages().len(), 1); + assert_eq!(pdf.pages()[0].render_dimensions(), (100.0, 400.0)); + assert_eq!(export(&d, &PdfOptions { artboards: Some(vec![5]), ..Default::default() }), Err(PdfError::BadArtboard(5))); + d.artboards.clear(); + assert_eq!(export(&d, &PdfOptions::default()), Err(PdfError::NoArtboards)); +} + +#[test] +fn roundtrip_rect_and_ellipse_bounds() { + let mut d = doc(300.0, 200.0); + add(&mut d, rect_node(Rect::new(10.0, 20.0, 110.0, 70.0), Color::rgb(1.0, 0.0, 0.0))); + let e = Node::path( + NodeId(0), + shapes::ellipse(Rect::new(150.0, 40.0, 250.0, 180.0)), + Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), + ); + add(&mut d, e); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 2); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 110.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); + assert!(close(l[1].geometric_bounds().unwrap(), Rect::new(150.0, 40.0, 250.0, 180.0), 0.05), "{:?}", l[1].geometric_bounds()); + assert_eq!(out.artboards.len(), 1); + assert!(close(out.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0), 1e-6)); +} + +#[test] +fn roundtrip_colours_preserved() { + let mut d = doc(300.0, 100.0); + let cols = [Color::rgb8(0x12, 0x34, 0x56), Color::rgb8(0xff, 0x80, 0x00), Color::rgb8(0x00, 0xc0, 0x40)]; + for (i, c) in cols.iter().enumerate() { + add(&mut d, rect_node(Rect::new(10.0 + 90.0 * i as f64, 10.0, 90.0 + 90.0 * i as f64, 90.0), *c)); + } + let out = roundtrip(&d); + let got: Vec = leaves(&out).iter().map(|n| n.appearance.fill_paint().color().unwrap().to_hex()).collect(); + assert_eq!(got, cols.iter().map(|c| c.to_hex()).collect::>()); +} + +#[test] +fn roundtrip_stroke_params_merge_fill_and_stroke() { + let mut d = doc(200.0, 200.0); + let mut p = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 4.0), + ); + { + let st = p.appearance.stroke_mut().unwrap(); + st.cap = LineCap::Round; + st.join = LineJoin::Bevel; + st.miter_limit = 7.0; + st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.0, align_corners: false }); + } + add(&mut d, p); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1, "fill + stroke of one path should import as one object"); + let st = l[0].appearance.stroke().unwrap(); + assert!((st.width - 4.0).abs() < 1e-3); + assert_eq!(st.cap, LineCap::Round); + assert_eq!(st.join, LineJoin::Bevel); + assert!((st.miter_limit - 7.0).abs() < 1e-3); + let dash = st.dash.as_ref().unwrap(); + assert_eq!(dash.pattern.len(), 2); + assert!((dash.pattern[0] - 6.0).abs() < 1e-3 && (dash.offset - 1.0).abs() < 1e-3); + assert_eq!(l[0].appearance.fill_paint().color().unwrap().to_hex(), "#ffffff"); +} + +#[test] +fn roundtrip_open_path_and_even_odd() { + let mut d = doc(200.0, 200.0); + let mut bp = drawcraft_geom::BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.curve_to((50.0, 0.0), (80.0, 100.0), (150.0, 60.0)); + add(&mut d, Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0))); + let mut ring = shapes::rectangle(Rect::new(20.0, 100.0, 120.0, 190.0)); + ring.subpaths.extend(shapes::rectangle(Rect::new(40.0, 120.0, 100.0, 170.0)).subpaths); + let mut n = rect_node(Rect::ZERO, Color::BLACK); + n.kind = NodeKind::Path { path: ring, rule: FillRule::EvenOdd, live: None, clipping: false, guide: false }; + add(&mut d, n); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 2); + let pb = l[0].path_data().unwrap(); + assert!(!pb.is_closed()); + let b = l[0].geometric_bounds().unwrap(); + let want = bp.bounding_box_exact(); + assert!(close(b, want, 0.05), "{b:?} vs {want:?}"); + assert!(matches!(l[1].kind, NodeKind::Path { rule: FillRule::EvenOdd, .. })); + assert_eq!(l[1].path_data().unwrap().subpaths.len(), 2); +} + +trait Exact { + fn bounding_box_exact(&self) -> Rect; +} +impl Exact for drawcraft_geom::BezPath { + fn bounding_box_exact(&self) -> Rect { + kurbo::Shape::bounding_box(self) + } +} + +#[test] +fn gradients_exported_as_shadings_and_reimported() { + let mut d = doc(300.0, 100.0); + let lin = GradientPaint::new(Gradient::default()); + let mut n = rect_node(Rect::new(0.0, 0.0, 140.0, 100.0), Color::BLACK); + n.appearance.set_fill(Paint::Gradient(Box::new(lin))); + add(&mut d, n); + let rad = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() }); + let mut n = rect_node(Rect::new(150.0, 0.0, 250.0, 100.0), Color::BLACK); + n.appearance.set_fill(Paint::Gradient(Box::new(rad))); + add(&mut d, n); + let s = uncompressed(&d); + let s = s.replace(' ', ""); + assert!(s.contains("/ShadingType2"), "axial shading"); + assert!(s.contains("/ShadingType3"), "radial shading"); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 2); + let Paint::Gradient(g) = l[0].appearance.fill_paint() else { panic!("linear gradient lost: {:?}", l[0].appearance) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + let geom = g.geom.unwrap(); + assert!((geom.start.x - 0.0).abs() < 0.1 && (geom.end.x - 140.0).abs() < 0.1, "{geom:?}"); + assert_eq!(g.gradient.stops.first().unwrap().color.to_hex(), "#ffffff"); + assert_eq!(g.gradient.stops.last().unwrap().color.to_hex(), "#000000"); + let Paint::Gradient(g) = l[1].appearance.fill_paint() else { panic!("radial gradient lost") }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + assert!((g.geom.unwrap().start - Point::new(200.0, 50.0)).hypot() < 0.1); +} + +#[test] +fn hidden_objects_and_template_layers_skipped() { + let mut d = doc(200.0, 200.0); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); + let mut hidden = rect_node(Rect::new(60.0, 10.0, 90.0, 50.0), Color::BLACK); + hidden.visible = false; + add(&mut d, hidden); + let t = d.add_layer(Some("Template")); + if let NodeKind::Layer { template, .. } = &mut d.node_mut(t).unwrap().kind { + *template = true; + } + let mut n = rect_node(Rect::new(100.0, 100.0, 150.0, 150.0), Color::BLACK); + n.id = d.alloc_id(); + d.insert(Some(t), 0, n).unwrap(); + let mut guide = rect_node(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + if let NodeKind::Path { guide: g, .. } = &mut guide.kind { + *g = true; + } + add(&mut d, guide); + let out = roundtrip(&d); + assert_eq!(leaves(&out).len(), 1); +} + +#[test] +fn objects_outside_artboard_are_culled() { + let mut d = doc(100.0, 100.0); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); + add(&mut d, rect_node(Rect::new(500.0, 500.0, 550.0, 550.0), Color::BLACK)); + assert_eq!(leaves(&roundtrip(&d)).len(), 1); +} + +#[test] +fn opacity_and_blend_modes() { + let mut d = doc(200.0, 200.0); + let mut n = rect_node(Rect::new(10.0, 10.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0)); + n.opacity = 0.5; + n.blend = BlendMode::Multiply; + add(&mut d, n); + let s = uncompressed(&d); + let s = s.replace(' ', ""); + assert!(s.contains("/BM/Multiply"), "blend mode in ExtGState"); + assert!(s.contains("/ca0.5"), "opacity in ExtGState"); + assert!(s.contains("/Transparency"), "transparency group"); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + // Opacity/blend end up on the object or an enclosing group. + let mut op = 1.0; + let mut bm = BlendMode::Normal; + out.walk(|n| { + op *= n.opacity * n.appearance.fill().map_or(1.0, |f| f.opacity); + if n.blend != BlendMode::Normal { + bm = n.blend; + } + }); + assert!((op - 0.5).abs() < 0.01, "{op}"); + assert_eq!(bm, BlendMode::Multiply); +} + +#[test] +fn clip_group_roundtrip() { + let mut d = doc(200.0, 200.0); + let clip = Node::new( + NodeId(0), + NodeKind::Path { + path: shapes::ellipse(Rect::new(20.0, 20.0, 120.0, 120.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let mut clip = clip; + clip.id = d.alloc_id(); + let mut a = rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 1.0, 0.0)); + a.id = d.alloc_id(); + let mut b = rect_node(Rect::new(50.0, 50.0, 150.0, 150.0), Color::rgb(0.0, 0.0, 1.0)); + b.id = d.alloc_id(); + let g = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(a), Arc::new(b)], clip: true }); + add(&mut d, g); + let out = roundtrip(&d); + let mut groups = vec![]; + out.walk(|n| { + if let NodeKind::Group { clip: true, children } = &n.kind { + groups.push(children.len()); + } + }); + assert_eq!(groups, vec![3]); + let cg = { + let mut r = None; + out.walk(|n| { + if matches!(n.kind, NodeKind::Group { clip: true, .. }) { + r = n.geometric_bounds(); + } + }); + r.unwrap() + }; + assert!(close(cg, Rect::new(20.0, 20.0, 120.0, 120.0), 0.05), "{cg:?}"); +} + +#[test] +fn text_exported_as_outlines() { + let mut d = doc(300.0, 100.0); + let t = TextObject::point(Point::new(10.0, 50.0), "Hello", CharStyle { size: 24.0, ..CharStyle::default() }); + add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))); + let s = uncompressed(&d); + assert!(!s.contains("/Font"), "no fonts when outlining text"); + let r = import_with_report(&export(&d, &PdfOptions::default()).unwrap(), &ImportOptions::default()).unwrap(); + let l = leaves(&r.document); + assert!(!l.is_empty()); + assert!(l.iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + let b = l.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.geometric_bounds())).unwrap(); + assert!(b.x0 >= 9.0 && b.x1 < 120.0 && b.y1 <= 56.0 && b.y0 > 25.0, "{b:?}"); +} + +fn tiny_png() -> Vec { + let mut img = image::RgbaImage::new(4, 2); + for (x, _, p) in img.enumerate_pixels_mut() { + *p = image::Rgba([if x < 2 { 255 } else { 0 }, 0, 0, 255]); + } + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +#[test] +fn image_roundtrip() { + let mut d = doc(200.0, 200.0); + d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(tiny_png()) }); + let im = ImageObject { key: "img1".into(), width: 4, height: 2, xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None }; + add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + let NodeKind::Image(im) = &l[0].kind else { panic!("expected image, got {:?}", l[0].kind_label()) }; + assert_eq!((im.width, im.height), (4, 2)); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 60.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); + let blob = &out.images[&im.key]; + let decoded = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]); +} + +/// Build a PDF with a correct xref table from object bodies. +fn handmade_pdf(content: &str, media: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources << >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1), + ]; + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offs = vec![]; + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).bytes()); + for o in offs { + out.extend(format!("{o:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).bytes()); + out +} + +#[test] +fn import_handmade_pdf() { + let content = "1 0 0 RG 0 0 1 rg 3 w 10 10 50 30 re B \ + 0 1 1 0 k 100 100 20 20 re f \ + 0.5 g q 1 0 0 1 150 0 cm 0 0 m 40 0 l 20 30 l h f Q"; + let bytes = handmade_pdf(content, "[0 0 200 300]"); + let r = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + let d = r.document; + assert!(close(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 300.0), 1e-9)); + let l = leaves(&d); + assert_eq!(l.len(), 3); + // y-up PDF rect (10,10)-(60,40) → y-down (10,260)-(60,290). + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(10.0, 260.0, 60.0, 290.0), 1e-6), "{:?}", l[0].geometric_bounds()); + assert_eq!(l[0].appearance.fill_paint().color().unwrap().to_hex(), "#0000ff"); + assert_eq!(l[0].appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); + assert!((l[0].appearance.stroke_width() - 3.0).abs() < 1e-6); + // CMYK red-ish (0,1,1,0) → approximately red through the CMYK profile. + let [r_, g_, b_] = l[1].appearance.fill_paint().color().unwrap().to_rgb(); + assert!(r_ > 0.8 && g_ < 0.3 && b_ < 0.3, "{r_} {g_} {b_}"); + // Gray 0.5 and the `cm` translation. + assert_eq!(l[2].appearance.fill_paint().color().unwrap().to_hex(), "#808080"); + assert!(close(l[2].geometric_bounds().unwrap(), Rect::new(150.0, 270.0, 190.0, 300.0), 1e-6)); +} + +#[test] +fn import_clip_from_handmade_pdf() { + let content = "q 0 0 50 50 re W n 1 0 0 rg 0 0 100 100 re f Q"; + let d = import(&handmade_pdf(content, "[0 0 100 100]")).unwrap(); + let mut clip_groups = 0; + d.walk(|n| { + if matches!(n.kind, NodeKind::Group { clip: true, .. }) { + clip_groups += 1; + assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-6)); + } + }); + assert_eq!(clip_groups, 1); +} + +#[test] +fn import_max_pages_and_multi_page() { + let mut d = doc(200.0, 100.0); + d.artboards.push(Artboard { + id: 2, + name: "B".into(), + rect: Rect::new(300.0, 0.0, 400.0, 400.0), + show_center_mark: false, + show_cross_hairs: false, + }); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); + add(&mut d, rect_node(Rect::new(310.0, 10.0, 350.0, 50.0), Color::BLACK)); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let all = import(&bytes).unwrap(); + assert_eq!(all.artboards.len(), 2); + assert_eq!(all.layers.len(), 2); + assert!((all.artboards[1].rect.width() - 100.0).abs() < 1e-9 && (all.artboards[1].rect.height() - 400.0).abs() < 1e-9); + // The second page's rect lands at the same place relative to its artboard. + let second = leaves(&all)[1].geometric_bounds().unwrap(); + let ab = all.artboards[1].rect; + assert!(close(second, Rect::new(ab.x0 + 10.0, 10.0, ab.x0 + 50.0, 50.0), 0.01), "{second:?}"); + let one = import_with_report(&bytes, &ImportOptions { max_pages: Some(1), ..Default::default() }).unwrap(); + assert_eq!(one.document.artboards.len(), 1); + assert_eq!(leaves(&one.document).len(), 1); +} + +#[test] +fn import_rejects_garbage() { + assert!(matches!(import(b"not a pdf at all"), Err(PdfError::Parse(_)) | Err(PdfError::NoPages))); +} + +#[test] +fn compatibility_levels() { + let mut d = doc(100.0, 100.0); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::rgb(1.0, 0.0, 0.0))); + let b14 = export(&d, &PdfOptions { compatibility: Compatibility::Pdf14, ..Default::default() }).unwrap(); + assert!(b14.starts_with(b"%PDF-1.4")); + let a = export(&d, &PdfOptions { compatibility: Compatibility::PdfA2b, ..Default::default() }).unwrap(); + assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP"); + assert!(matches!(export(&d, &PdfOptions { compatibility: Compatibility::PdfX4, ..Default::default() }), Err(PdfError::Unsupported(_)))); +} + +#[test] +fn compress_option_changes_output() { + let mut d = doc(100.0, 100.0); + for i in 0..20 { + add(&mut d, rect_node(Rect::new(i as f64, i as f64, 50.0 + i as f64, 50.0), Color::BLACK)); + } + let c = export(&d, &PdfOptions::default()).unwrap(); + let u = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + assert!(u.len() > c.len()); + assert!(String::from_utf8_lossy(&u).contains(" re") || String::from_utf8_lossy(&u).contains(" l")); +} + +#[test] +fn stroke_alignment_and_arrowheads_export() { + let mut d = doc(200.0, 200.0); + let mut p = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0), + ); + p.appearance.stroke_mut().unwrap().align = drawcraft_doc::StrokeAlign::Inside; + add(&mut d, p); + let mut line = Node::path( + NodeId(0), + shapes::line(Point::new(10.0, 150.0), Point::new(150.0, 150.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0), + ); + line.appearance.stroke_mut().unwrap().end_arrow = Some(drawcraft_doc::Arrowhead::Triangle); + add(&mut d, line); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + let out = import(&r.bytes).unwrap(); + let mut widths = vec![]; + out.walk(|n| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + widths.push(s.width); + } + }); + assert!(widths.iter().any(|w| (w - 12.0).abs() < 1e-3), "inside stroke doubled and clipped: {widths:?}"); + // The arrowhead is an extra filled object. + assert!(leaves(&out).len() >= 3); +} + +#[test] +fn creation_date_written() { + let d = doc(100.0, 100.0); + // 2024-02-29 12:34:56 UTC + let bytes = export(&d, &PdfOptions { created: Some(1_709_210_096), compress: false, ..Default::default() }).unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456")); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index d739eaed..260fb235 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -13,6 +13,12 @@ drawcraft-geom = { workspace = true } drawcraft-color = { workspace = true } drawcraft-doc = { workspace = true } drawcraft-text = { workspace = true } +drawcraft-effects = { workspace = true } vello_cpu = { workspace = true } +# Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. +vello_common = { version = "0.2", default-features = false } image = { workspace = true } log = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs new file mode 100644 index 00000000..3135bf3d --- /dev/null +++ b/crates/render/src/fx.rs @@ -0,0 +1,362 @@ +//! Live effects in the renderer. +//! +//! Geometry effects (object-level, then per fill/stroke) rewrite the path before painting via +//! `drawcraft-effects`. Raster effects use vello_cpu filter layers (single-threaded contexts only, +//! which is what the renderer creates): +//! +//! - Drop Shadow / Outer Glow: the object's silhouette in a `DropShadowOnly` filter layer, painted +//! below the object with the effect's blend mode and opacity; +//! - Gaussian Blur: the object painted inside a Gaussian filter layer; +//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward; +//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped +//! to the shape and painted above it. + +use drawcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; +use drawcraft_effects::{self as effects, GeomContext, RasterFx}; +use drawcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; +use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use vello_cpu::RenderContext; +use vello_cpu::peniko; + +use crate::{Frame, Renderer, blend_mode, fill_rule, paint}; + +fn visible(effects: &[Effect]) -> bool { + effects.iter().any(|e| e.visible) +} + +/// Does this node need the effects path (a path or compound path with visible effects)? +pub(crate) fn has_fx(n: &Node) -> bool { + if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { + return false; + } + visible(&n.appearance.effects) + || n.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => visible(&f.effects), + AppearanceItem::Stroke(s) => visible(&s.effects), + }) +} + +fn node_bezpath(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { children, .. } => { + let mut bp = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + bp.extend(p.to_bezpath()); + } + } + Some(bp) + } + _ => None, + } +} + +fn geom_ctx(n: &Node) -> GeomContext { + let w = n.appearance.stroke_width(); + GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } } +} + +fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { + if !effects::has_geometry(effects) || bp.elements().is_empty() { + return bp.clone(); + } + effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx) +} + +/// The object-level effected geometry of `n` (base path `bp`). +pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { + apply(&n.appearance.effects, bp, &geom_ctx(n)) +} + +fn item_effects(item: &AppearanceItem) -> &[Effect] { + match item { + AppearanceItem::Fill(f) => &f.effects, + AppearanceItem::Stroke(s) => &s.effects, + } +} + +/// Visual bounds including geometry effects, stroke outsets and shadows/glows. +pub(crate) fn visual_bounds(n: &Node) -> Option { + let bp = node_bezpath(n)?; + let ctx = geom_ctx(n); + let g = effected_path(n, &bp); + let mut r: Option = (!g.elements().is_empty()).then(|| g.bounding_box()); + for item in &n.appearance.items { + let fx = item_effects(item); + if effects::has_geometry(fx) { + let ig = apply(fx, &g, &ctx); + if !ig.elements().is_empty() { + let b = ig.bounding_box(); + r = Some(r.map_or(b, |r| r.union(b))); + } + } + } + let o = n.appearance.outset() + effects::outset(&n.appearance.effects); + r.map(|r| r.inflate(o, o)) +} + +/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in +/// the subtree (a shadow can reach the view while its object is outside it). +pub(crate) fn cull_bounds(n: &Node) -> Option { + match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, cull_bounds(c))) + } + _ if has_fx(n) => visual_bounds(n), + _ => n.visual_bounds(), + } +} + +fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { + Filter::from_primitive(FilterPrimitive::DropShadowOnly { + dx: dx as f32, + dy: dy as f32, + std_deviation: (blur / 2.0).max(0.0) as f32, + color, + edge_mode: EdgeMode::None, + }) +} + +fn blur_filter(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None }) +} + +fn pcolor(c: &drawcraft_doc::color::Color) -> peniko::Color { + let [r, g, b] = c.to_rgb(); + peniko::Color::new([r, g, b, 1.0]) +} + +impl Renderer { + /// `draw_shape` for nodes with live effects. + pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + let g = effected_path(n, bp); + if g.elements().is_empty() { + return; + } + if f.opts.outline { + self.hairline(ctx, f, &g, [0, 0, 0, 255]); + return; + } + let gctx = geom_ctx(n); + let rfx = effects::raster_effects(&n.appearance.effects); + // Below the object: shadows and outer glows. + for fx in rfx.iter().filter(|x| x.is_below()) { + // The offset is applied to the geometry rather than in the filter: vello_cpu drops + // layer content that lies entirely outside the viewport before filtering. + let (mode, opacity, dx, dy, filter) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { + (*mode, *opacity, *dx, *dy, shadow_filter(0.0, 0.0, *blur, pcolor(color))) + } + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, shadow_filter(0.0, 0.0, *blur, pcolor(color))), + _ => continue, + }; + let mut gs = g.clone(); + gs.apply_affine(Affine::translate((dx, dy))); + ctx.set_transform(f.view); + ctx.push_layer(None, Some(blend_mode(mode)), Some(opacity), None, Some(filter)); + self.paint_items(ctx, f, n, &gs, rule, &gctx); + ctx.pop_layer(); + } + // The object itself (blurred / feathered). + let mut layers = 0; + for fx in &rfx { + match fx { + RasterFx::Feather { radius } if *radius > 0.0 => { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(rule)); + ctx.push_clip_layer(&g); + ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); + layers += 2; + } + RasterFx::GaussianBlur { radius } if *radius > 0.0 => { + ctx.set_transform(f.view); + ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); + layers += 1; + } + _ => {} + } + } + self.paint_items(ctx, f, n, &g, rule, &gctx); + for _ in 0..layers { + ctx.pop_layer(); + } + // Above the object: inner glows, clipped to the shape. + for fx in &rfx { + let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(rule)); + ctx.push_clip_layer(&g); + ctx.push_layer(None, Some(blend_mode(*mode)), Some(*opacity), None, Some(shadow_filter(0.0, 0.0, *blur, pcolor(color)))); + ctx.set_paint(peniko::Color::BLACK); + if *center { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(&g); + } else { + // Everything outside the shape, so the glow bleeds in from the edges. + let pad = blur * 2.0 + 4.0 * f.px; + let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); + inv.extend(g.iter()); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.fill_path(&inv); + } + ctx.pop_layer(); + ctx.pop_layer(); + } + ctx.set_transform(Affine::IDENTITY); + } + + /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects. + fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) { + let bounds = g.bounding_box(); + for item in &n.appearance.items { + let ig = apply(item_effects(item), g, gctx); + let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; + match item { + AppearanceItem::Fill(fl) => { + if !fl.visible || fl.paint.is_none() { + continue; + } + let layered = fl.opacity < 1.0 || fl.blend != drawcraft_doc::color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, ib, f.doc) { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(&ig); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) => { + if !st.visible || st.paint.is_none() || st.width <= 0.0 { + continue; + } + self.draw_stroke(ctx, f, &ig, rule, st, ib); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use drawcraft_doc::color::{Color, Paint}; + use drawcraft_doc::{Appearance, Document, Effect, Node}; + use drawcraft_geom::{Affine, Rect, shapes}; + use serde_json::json; + + use crate::{RenderOptions, Rendered, Renderer}; + + fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); + n.appearance.effects = effects.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + } + + fn render(d: &Document) -> Rendered { + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) + } + + fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 + } + + const WHITE: u32 = 765; + + #[test] + fn drop_shadow_darkens_outside_the_shape() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let plain = render(&doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![])); + assert_eq!(lum(plain.pixel(66, 66)), WHITE); + let d = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 2, "opacity": 100}))]); + let img = render(&d); + assert!(lum(img.pixel(66, 66)) < 200, "shadow below-right: {:?}", img.pixel(66, 66)); + // The object stays on top and unshadowed; the far side has no shadow. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255]); + assert_eq!(lum(img.pixel(15, 15)), WHITE); + } + + #[test] + fn shadow_of_offscreen_object_is_not_culled() { + let d = doc_with( + Rect::new(-60.0, 20.0, -5.0, 60.0), + Color::WHITE, + vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))], + ); + let img = render(&d); + let row: Vec = (0..10).map(|i| lum(img.pixel(i * 10, 40))).collect(); + assert!(lum(img.pixel(10, 40)) < 100, "{row:?}"); + } + + #[test] + fn geometry_effect_is_rendered() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let d = doc_with(r, Color::BLACK, vec![("path.offsetPath", json!({"offset": 10}))]); + let img = render(&d); + assert_eq!(lum(img.pixel(25, 50)), 0, "offset grows the fill"); + let d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); + let img = render(&d); + assert_eq!(lum(img.pixel(10, 50)), 0); + assert_eq!(lum(img.pixel(60, 50)), WHITE); + } + + #[test] + fn per_fill_effects_apply_to_that_fill_only() { + let mut d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![]); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.appearance.fill_mut().unwrap().effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 20}), visible: true }); + let img = render(&d); + assert_eq!(lum(img.pixel(85, 50)), 0); + assert_eq!(lum(img.pixel(40, 50)), WHITE); + } + + #[test] + fn blur_glow_and_feather_soften() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let blur = render(&doc_with(r, Color::BLACK, vec![("blur.gaussian", json!({"radius": 6}))])); + let edge = lum(blur.pixel(29, 50)); + assert!(edge > 0 && edge < WHITE, "blur spreads over the edge: {edge}"); + let glow = + render(&doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))])); + let g = glow.pixel(27, 50); + assert!(g[0] > g[1] + 20, "outer glow is red: {g:?}"); + let inner = + render(&doc_with(r, Color::BLACK, vec![("stylize.innerGlow", json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4}))])); + assert!(lum(inner.pixel(31, 50)) > 60, "inner glow lightens the edge: {:?}", inner.pixel(31, 50)); + assert!(lum(inner.pixel(50, 50)) < 30, "centre stays dark"); + let feather = render(&doc_with(r, Color::BLACK, vec![("stylize.feather", json!({"radius": 8}))])); + assert!(lum(feather.pixel(31, 50)) > 20, "feather fades the edge inward"); + assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside"); + } + + #[test] + fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let mut d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); + let id = d.layers[0].children().unwrap()[0].id; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(lum(img.pixel(5, 50)) < 400, "outline follows the moved path"); + d.node_mut(id).unwrap().appearance.effects[0].visible = false; + let img = render(&d); + assert_eq!(lum(img.pixel(50, 50)), 0); + assert_eq!(lum(img.pixel(10, 50)), WHITE); + } + + #[test] + fn visual_bounds_include_shadow() { + let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]); + let n = &d.layers[0].children().unwrap()[0]; + let b = super::visual_bounds(n).unwrap(); + assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 363f2eb3..de0d702a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,7 @@ //! gradients, images and text (via `drawcraft-text` glyph outlines). #![forbid(unsafe_code)] +mod fx; mod paint; use std::collections::HashMap; @@ -17,6 +18,7 @@ use vello_cpu::kurbo; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{Pixmap, RenderContext, Resources}; +pub use drawcraft_effects as effects; pub use vello_cpu; /// Rendering options. @@ -171,7 +173,7 @@ impl Renderer { /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { let n = doc.node(id)?; - let b = n.visual_bounds()?; + let b = fx::cull_bounds(n)?; let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let w = size.clamp(1, u16::MAX as u32) as u16; @@ -188,7 +190,7 @@ impl Renderer { if !force && (!n.visible || f.opts.hidden.contains(&n.id)) { return; } - if let Some(b) = n.visual_bounds() { + if let Some(b) = fx::cull_bounds(n) { let pad = f.px * 2.0; if !rects_overlap(b.inflate(pad, pad), f.visible) { self.stats.culled += n.count(); @@ -274,6 +276,9 @@ impl Renderer { } fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if fx::has_fx(n) { + return self.draw_shape_fx(ctx, f, n, bp, rule); + } if f.opts.outline { self.hairline(ctx, f, bp, [0, 0, 0, 255]); return; diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index f0e16305..5f1bc912 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -1,8 +1,169 @@ //! Drawing and path-editing tools (pencil, curvature, anchor tools, scissors, knife, line family…). +//! +//! Like every tool these only emit [`crate::Action`]s; the geometry work happens in the engine's +//! `path.*` commands (`crates/engine/src/cmd/draw2.rs`), so gestures replay through the journal. -use crate::Tool; +mod anchor; +mod curvature; +mod family; +mod gesture; + +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath}; +use serde_json::{Value, json}; + +use crate::{Tool, ToolContext}; + +pub use anchor::AnchorTool; +pub use curvature::CurvatureTool; +pub use family::FamilyTool; +pub use gesture::GestureTool; + +/// Colour of tool feedback (Illustrator's selection blue). +pub const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; /// Create a drawing tool by id (None = not one of ours). -pub fn create(_id: &str) -> Option> { +pub fn create(id: &str) -> Option> { + Some(match id { + "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser" | "join" => Box::new(GestureTool::new(id)), + "curvature" => Box::new(CurvatureTool::default()), + "addAnchor" | "deleteAnchor" | "anchorPoint" | "scissors" => Box::new(AnchorTool::new(id)), + "arc" | "spiral" | "rectangularGrid" | "polarGrid" => Box::new(FamilyTool::new(id)), + _ => return None, + }) +} + +/// A spline through `points` (Catmull-Rom converted to cubic Béziers). Points flagged `true` are +/// corners (no handles); open ends have no handles either. Used by the Curvature tool and by the +/// engine's freehand fitting. +pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath { + let n = points.len(); + let mut anchors = Vec::with_capacity(n); + for i in 0..n { + let (p, corner) = points[i]; + let ends = !closed && (i == 0 || i + 1 == n); + if corner || ends || n < 3 { + anchors.push(Anchor::corner(p)); + continue; + } + let prev = points[(i + n - 1) % n].0; + let next = points[(i + 1) % n].0; + let t = (next - prev) / 6.0; + if t.hypot() < 1e-9 { + anchors.push(Anchor::corner(p)); + continue; + } + anchors.push(Anchor { p, h_in: p - t, h_out: p + t, kind: AnchorKind::Smooth }); + } + SubPath::new(anchors, closed && n > 2) +} + +/// `[[x, y], …]` for command params. +pub fn points_json(pts: &[Point]) -> Value { + Value::Array(pts.iter().map(|p| json!([p.x, p.y])).collect()) +} + +/// A polyline preview path. +pub fn polyline(pts: &[Point]) -> BezPath { + let mut bp = BezPath::new(); + if let Some(f) = pts.first() { + bp.move_to(*f); + for p in &pts[1..] { + bp.line_to(*p); + } + } + bp +} + +/// Editable path nodes, selected ones first, then the rest top-most first. +fn candidate_paths(cx: &ToolContext) -> Vec { + let mut v: Vec = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| n.path_data().is_some())).collect(); + let mut rest = vec![]; + cx.doc.walk(|n| { + if let NodeKind::Path { guide: false, .. } = &n.kind { + rest.push(n.id); + } + }); + rest.reverse(); + for id in rest { + if !v.contains(&id) { + v.push(id); + } + } + v.retain(|id| cx.doc.is_editable(*id)); + v +} + +/// Nearest anchor within `tol`: (path, subpath, anchor). +pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> { + let mut best: Option<((NodeId, usize, usize), f64)> = None; + for id in candidate_paths(cx) { + let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue }; + for (si, ai, a) in pd.anchors() { + let d = a.p.distance(p); + if d <= tol && best.is_none_or(|b| d < b.1 - 1e-9) { + best = Some(((id, si, ai), d)); + } + } + if best.is_some() { + break; + } + } + best.map(|b| b.0) +} + +/// Nearest point on a path segment within `tol`: (path, subpath, segment, t). +pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> { + for id in candidate_paths(cx) { + let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue }; + if let Some((si, seg, t, _, d)) = pd.nearest(p) + && d <= tol + { + return Some((id, si, seg, t)); + } + } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catmull_rom_passes_through_points() { + let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)]; + let sp = catmull_rom(&pts, false); + assert_eq!(sp.anchors.len(), 3); + assert_eq!(sp.anchors[1].p, Point::new(50.0, 50.0)); + assert_eq!(sp.anchors[1].kind, AnchorKind::Smooth); + // The middle tangent is horizontal (parallel to first → last). + assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9); + assert!(!sp.anchors[0].has_out()); + } + + #[test] + fn all_tools_exist() { + for id in [ + "pencil", + "paintbrush", + "blobBrush", + "eraser", + "knife", + "smooth", + "pathEraser", + "join", + "curvature", + "addAnchor", + "deleteAnchor", + "anchorPoint", + "scissors", + "arc", + "spiral", + "rectangularGrid", + "polarGrid", + ] { + assert_eq!(create(id).unwrap().id(), id); + } + assert!(create("selection").is_none()); + } +} diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs new file mode 100644 index 00000000..9495544e --- /dev/null +++ b/crates/tools/src/draw2/anchor.rs @@ -0,0 +1,251 @@ +//! Add Anchor Point (+), Delete Anchor Point (−), Anchor Point (Shift+C) and Scissors (C). +//! +//! Add: click a segment to insert an anchor without changing the shape (Alt = delete). +//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add). +//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a +//! handle → move it independently; drag a segment → reshape the curve. +//! Scissors: click a path to split it there. + +use drawcraft_doc::NodeId; +use drawcraft_geom::Point; +use serde_json::json; + +use super::{hit_anchor, hit_segment}; +use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Clone, Copy, Debug)] +enum State { + Idle, + Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool }, + Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool }, + Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool }, +} + +pub struct AnchorTool { + id: &'static str, + state: State, +} + +impl AnchorTool { + pub fn new(id: &str) -> Self { + let id: &'static str = match id { + "deleteAnchor" => "deleteAnchor", + "anchorPoint" => "anchorPoint", + "scissors" => "scissors", + _ => "addAnchor", + }; + Self { id, state: State::Idle } + } + + fn add(cx: &ToolContext, p: Point) -> Vec { + match hit_segment(cx, p, cx.tol(4.0)) { + Some((id, si, seg, t)) => vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))], + None => vec![], + } + } + + fn delete(cx: &ToolContext, p: Point) -> Vec { + match hit_anchor(cx, p, cx.tol(4.0)) { + Some((id, si, ai)) => vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))], + None => vec![], + } + } +} + +/// A direction handle of a selected path under `p`: (id, subpath, anchor, is_out). +fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> { + for id in &cx.selection.objects { + let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; + for (si, ai, a) in pd.anchors() { + if a.has_out() && a.h_out.distance(p) <= tol { + return Some((*id, si, ai, true)); + } + if a.has_in() && a.h_in.distance(p) <= tol { + return Some((*id, si, ai, false)); + } + } + } + None +} + +impl Tool for AnchorTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + !matches!(self.state, State::Idle) + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let tol = cx.tol(4.0); + match (self.id, ev.kind) { + ("addAnchor", PointerKind::Down) => { + if ev.mods.alt { + Self::delete(cx, p) + } else { + Self::add(cx, p) + } + } + ("deleteAnchor", PointerKind::Down) => { + if ev.mods.alt { + Self::add(cx, p) + } else { + Self::delete(cx, p) + } + } + ("scissors", PointerKind::Down) => { + if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))]; + } + match hit_segment(cx, p, tol) { + Some((id, si, seg, t)) => vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))], + None => vec![], + } + } + ("anchorPoint", PointerKind::Down) => { + if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + self.state = State::Handle { id, si, ai, out, began: false }; + } else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + self.state = State::Convert { id, si, ai, start: p, began: false }; + } else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) { + self.state = State::Reshape { id, si, seg, t, start: p, began: false }; + } + vec![] + } + ("anchorPoint", PointerKind::Drag) => { + let mut out = vec![]; + let (began, far) = match self.state { + State::Idle => return out, + State::Convert { began, start, .. } | State::Reshape { began, start, .. } => (began, p.distance(start) >= cx.tol(2.0)), + State::Handle { began, .. } => (began, true), + }; + if !began { + if !far { + return out; + } + out.push(Action::Begin(if matches!(self.state, State::Convert { .. }) { "Convert Anchor Point" } else { "Reshape" }.into())); + if let State::Convert { began, .. } | State::Handle { began, .. } | State::Reshape { began, .. } = &mut self.state { + *began = true; + } + } + out.push(match self.state { + State::Convert { id, si, ai, .. } => { + Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y})) + } + State::Handle { id, si, ai, out, .. } => Action::Preview( + "path.setHandle".into(), + json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}), + ), + State::Reshape { id, si, seg, t, start, .. } => Action::Preview( + "path.reshapeSegment".into(), + json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}), + ), + State::Idle => unreachable!(), + }); + out + } + ("anchorPoint", PointerKind::Up) => { + let st = std::mem::replace(&mut self.state, State::Idle); + match st { + State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => { + vec![Action::Commit] + } + State::Convert { id, si, ai, began: false, .. } => { + let has_handles = cx + .doc + .node(id) + .and_then(|n| n.path_data()) + .and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) + .is_some_and(|a| a.has_in() || a.has_out()); + if has_handles { + vec![Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "corner"}))] + } else { + vec![] + } + } + _ => vec![], + } + } + _ => vec![], + } + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + let st = std::mem::replace(&mut self.state, State::Idle); + match st { + State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => vec![Action::Commit], + _ => vec![], + } + } + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + match self.id { + "addAnchor" if !m.alt => Cursor::PenAdd, + "addAnchor" => Cursor::PenDelete, + "deleteAnchor" if !m.alt => Cursor::PenDelete, + "deleteAnchor" => Cursor::PenAdd, + "scissors" => { + if hit_segment(cx, p, cx.tol(4.0)).is_some() { + Cursor::Crosshair + } else { + Cursor::NotAllowed + } + } + _ => Cursor::Pen, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn add_and_delete_anchor_clicks() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("addAnchor"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0)); + assert!((t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0))).is_empty()); + let mut t = AnchorTool::new("deleteAnchor"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 101.0)); + assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); + } + + #[test] + fn scissors_splits_at_segment() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("scissors"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0)); + assert!( + matches!(&a[0], Action::Exec(c, v) if c == "path.split" && v["id"] == id.0 && v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-6) + ); + } + + #[test] + fn anchor_point_drag_pulls_handles_click_converts() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 90.0)); + assert_eq!(a[0], Action::Begin("Convert Anchor Point".into())); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.convertAnchor" && v["to"] == "smooth" && v["id"] == id.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 90.0)), vec![Action::Commit]); + // Clicking a corner without handles does nothing. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); + // Dragging a segment reshapes it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0)); + } +} diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs new file mode 100644 index 00000000..bd1025e1 --- /dev/null +++ b/crates/tools/src/draw2/curvature.rs @@ -0,0 +1,219 @@ +//! The Curvature tool (Shift+~). +//! +//! Each click adds a point and the path curves smoothly through all points. Alt-click or +//! double-click a point toggles it between smooth and corner; drag a point to move it; click the +//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path. +//! A rubber band shows the curve to the cursor. + +use drawcraft_doc::NodeId; +use drawcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{FEEDBACK, catmull_rom}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Default)] +pub struct CurvatureTool { + pts: Vec<(Point, bool)>, + closed: bool, + id: Option, + /// Point being dragged: (index, began). + drag: Option<(usize, Point, bool)>, + /// Last touched point (Backspace removes it). + current: Option, + hover: Option, +} + +impl CurvatureTool { + /// The path being drawn, if it is still the single selected object and matches our points. + fn active(&self, cx: &ToolContext) -> Option { + let id = self.id?; + if cx.selection.objects != [id] { + return None; + } + let pd = cx.doc.node(id)?.path_data()?; + (pd.subpaths.len() == 1 && pd.anchor_count() == self.pts.len()).then_some(id) + } + + fn params(&self) -> Value { + let pts: Vec = self.pts.iter().map(|(p, c)| json!({"x": p.x, "y": p.y, "corner": c})).collect(); + let mut v = json!({"points": pts, "closed": self.closed}); + if let Some(id) = self.id { + v["id"] = json!(id.0); + } + v + } + + fn hit_point(&self, cx: &ToolContext, p: Point) -> Option { + let tol = cx.tol(5.0); + self.pts.iter().position(|(q, _)| q.distance(p) <= tol) + } + + fn reset(&mut self) { + self.pts.clear(); + self.closed = false; + self.id = None; + self.drag = None; + self.current = None; + } +} + +impl Tool for CurvatureTool { + fn id(&self) -> &'static str { + "curvature" + } + fn busy(&self) -> bool { + self.drag.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let active = self.active(cx); + if active.is_none() && self.id.is_some() && matches!(ev.kind, PointerKind::Down) { + self.reset(); + } + match ev.kind { + PointerKind::Move => { + self.hover = Some(p); + vec![] + } + PointerKind::Down => { + if active.is_some() { + if let Some(i) = self.hit_point(cx, p) { + self.current = Some(i); + if ev.mods.alt { + self.pts[i].1 = !self.pts[i].1; + return vec![Action::Exec("path.curvature".into(), self.params())]; + } + if i == 0 && !self.closed && self.pts.len() >= 3 { + self.closed = true; + return vec![Action::Exec("path.curvature".into(), self.params())]; + } + self.drag = Some((i, p, false)); + return vec![]; + } + if !self.closed { + self.pts.push((p, ev.mods.alt)); + self.current = Some(self.pts.len() - 1); + return vec![Action::Exec("path.curvature".into(), self.params())]; + } + } + // Start a new path. + self.reset(); + self.pts.push((p, false)); + self.current = Some(0); + vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())] + } + PointerKind::Drag => { + let Some((i, start, began)) = self.drag else { return vec![] }; + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Curvature".into())); + self.drag = Some((i, start, true)); + } + if let Some(pt) = self.pts.get_mut(i) { + pt.0 = p; + } + out.push(Action::Preview("path.curvature".into(), self.params())); + out + } + PointerKind::Up => match self.drag.take() { + Some((_, _, true)) => vec![Action::Commit], + _ => vec![], + }, + PointerKind::DoubleClick => { + if active.is_none() { + return vec![]; + } + let Some(i) = self.hit_point(cx, p) else { return vec![] }; + self.pts[i].1 = !self.pts[i].1; + vec![Action::Exec("path.curvature".into(), self.params())] + } + } + } + fn notify(&mut self, cx: &ToolContext, what: &str) { + if what == "created" && cx.selection.objects.len() == 1 { + self.id = Some(cx.selection.objects[0]); + } + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + match key { + ToolKey::Escape | ToolKey::Enter => { + let dragging = self.drag.is_some_and(|d| d.2); + self.reset(); + if dragging { vec![Action::Cancel] } else { vec![] } + } + ToolKey::Backspace | ToolKey::Delete if self.active(cx).is_some() && self.pts.len() > 1 && self.drag.is_none() => { + let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1); + self.pts.remove(i); + if self.pts.len() < 3 { + self.closed = false; + } + self.current = None; + vec![Action::Exec("path.curvature".into(), self.params())] + } + _ => vec![], + } + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + let dragging = self.drag.is_some_and(|d| d.2); + self.reset(); + if dragging { vec![Action::Commit] } else { vec![] } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + if self.active(cx).is_none() { + return vec![]; + } + let mut o = vec![]; + if !self.closed + && self.drag.is_none() + && let Some(h) = self.hover + { + let mut pts = self.pts.clone(); + pts.push((h, false)); + let sp = catmull_rom(&pts, false); + let mut bp = drawcraft_geom::BezPath::new(); + sp.to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } + for (i, (p, _)) in self.pts.iter().enumerate() { + o.push(Overlay::Anchor { p: *p, color: FEEDBACK, filled: Some(i) == self.current, size: 6.0 }); + } + o + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Pen + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn first_click_creates_then_notify_tracks_path() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CurvatureTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v.get("id").is_none() && v["points"].as_array().unwrap().len() == 1)); + assert_eq!(a[1], Action::Notify("created".into())); + // Pretend the engine created `id` with one anchor: it is a rect (4 anchors) so it doesn't match. + let mut s2 = Selection::default(); + s2.set([id]); + let cx2 = crate::testutil::cx(&d, &s2, &p); + t.notify(&cx2, "created"); + assert_eq!(t.id, Some(id)); + assert!(t.active(&cx2).is_none()); + // With four points it matches, so a click appends a fifth. + t.pts = vec![(Point::new(100.0, 100.0), false); 4]; + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)); + assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5)); + } +} diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs new file mode 100644 index 00000000..90c02a22 --- /dev/null +++ b/crates/tools/src/draw2/family.rs @@ -0,0 +1,262 @@ +//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid. +//! +//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids); a click +//! without dragging asks the UI for the options dialog. While dragging, ↑/↓ change the spiral's +//! segments, the grid rows or the concentric dividers; ←/→ change grid columns / radial dividers. + +use drawcraft_geom::{Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +pub struct FamilyTool { + id: &'static str, + start: Option, + last: Point, + mods: Mods, + began: bool, + pub closed: bool, + pub decay: f64, + pub segments: u32, + pub clockwise: bool, + /// Rows / concentric dividers. + pub rows: u32, + /// Columns / radial dividers. + pub columns: u32, +} + +impl FamilyTool { + pub fn new(id: &str) -> Self { + let id: &'static str = match id { + "spiral" => "spiral", + "rectangularGrid" => "rectangularGrid", + "polarGrid" => "polarGrid", + _ => "arc", + }; + Self { + id, + start: None, + last: Point::ZERO, + mods: Mods::default(), + began: false, + closed: false, + decay: 80.0, + segments: 10, + clockwise: true, + rows: 5, + columns: 5, + } + } + + fn label(&self) -> &'static str { + match self.id { + "spiral" => "Spiral", + "rectangularGrid" => "Rectangular Grid", + "polarGrid" => "Polar Grid", + _ => "Arc", + } + } + + fn rect(start: Point, p: Point, m: Mods) -> Rect { + let mut d = p - start; + if m.shift { + let s = d.x.abs().max(d.y.abs()); + d = Vec2::new(s * d.x.signum(), s * d.y.signum()); + } + if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) } + } + + /// The command for a drag from `start` to `p`. + pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) { + match self.id { + "spiral" => { + let r = p.distance(start).max(0.01); + ( + "shape.spiral".into(), + json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), + ) + } + "rectangularGrid" => { + let r = Self::rect(start, p, m); + ( + "shape.rectangularGrid".into(), + json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}), + ) + } + "polarGrid" => { + let r = Self::rect(start, p, m); + ( + "shape.polarGrid".into(), + json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}), + ) + } + _ => { + let mut d = p - start; + if m.shift { + let s = d.x.abs().max(d.y.abs()); + d = Vec2::new(s * d.x.signum(), s * d.y.signum()); + } + let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) }; + ("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed})) + } + } + } +} + +impl Tool for FamilyTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.start.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.start = Some(p); + self.last = p; + self.began = false; + vec![] + } + PointerKind::Drag => { + let Some(s) = self.start else { return vec![] }; + self.last = ev.pos; + self.mods = ev.mods; + let mut out = vec![]; + if !self.began { + if ev.pos.distance(s) < cx.tol(2.0) { + return out; + } + self.began = true; + out.push(Action::Begin(self.label().into())); + } + let (c, v) = self.command(s, ev.pos, ev.mods); + out.push(Action::Preview(c, v)); + out + } + PointerKind::Up => { + let Some(s) = self.start.take() else { return vec![] }; + if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] } + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; + let changed = match (self.id, key) { + ("spiral", ToolKey::Up) => { + self.segments = (self.segments + 1).min(1000); + true + } + ("spiral", ToolKey::Down) => { + self.segments = self.segments.saturating_sub(1).max(2); + true + } + ("rectangularGrid" | "polarGrid", ToolKey::Up) => { + self.rows = (self.rows + 1).min(999); + true + } + ("rectangularGrid" | "polarGrid", ToolKey::Down) => { + self.rows = self.rows.saturating_sub(1); + true + } + ("rectangularGrid" | "polarGrid", ToolKey::Right) => { + self.columns = (self.columns + 1).min(999); + true + } + ("rectangularGrid" | "polarGrid", ToolKey::Left) => { + self.columns = self.columns.saturating_sub(1); + true + } + _ => false, + }; + if changed { + let (c, v) = self.command(s, self.last, self.mods); + vec![Action::Preview(c, v)] + } else if key == ToolKey::Escape { + self.start = None; + self.began = false; + vec![Action::Cancel] + } else { + vec![] + } + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + match self.start { + Some(s) if self.began => { + let d = self.last - s; + vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }] + } + _ => vec![], + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + match self.id { + "spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), + "rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}), + "polarGrid" => json!({"concentric": self.rows, "radial": self.columns}), + _ => json!({"closed": self.closed}), + } + } + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "closed" => self.closed = v.as_bool().unwrap_or(self.closed), + "decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 150.0), + "segments" => self.segments = v.as_u64().unwrap_or(10).clamp(2, 1000) as u32, + "clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise), + "rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32, + "columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32, + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn grid_drag_and_arrows() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = FamilyTool::new("rectangularGrid"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + assert_eq!(a[0], Action::Begin("Rectangular Grid".into())); + assert_eq!( + a[1], + Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5})) + ); + let a = t.key(&cx, ToolKey::Right, Mods::default()); + assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + } + + #[test] + fn click_opens_dialog_and_arc_shift() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = FamilyTool::new("spiral"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), + vec![Action::Dialog("spiral".into(), json!({"x": 10.0, "y": 10.0}))] + ); + let t = FamilyTool::new("arc"); + let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, ..Default::default() }); + assert_eq!(c, "shape.arc"); + assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false})); + } +} diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs new file mode 100644 index 00000000..e2ef430b --- /dev/null +++ b/crates/tools/src/draw2/gesture.rs @@ -0,0 +1,337 @@ +//! Freehand gesture tools: Pencil (N), Paintbrush (B), Blob Brush (Shift+B), Eraser (Shift+E), +//! Knife, Smooth, Path Eraser and Join. +//! +//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot) +//! and commit on release; Alt on release (or ending near the start) closes the path, and starting +//! near an end of a selected open path continues it. The other tools collect the drag polyline +//! (shown as an overlay) and run one command on release. + +use drawcraft_doc::NodeId; +use drawcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{FEEDBACK, points_json, polyline}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +pub struct GestureTool { + id: &'static str, + points: Vec, + active: bool, + began: bool, + /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). + extend: Option<(NodeId, bool)>, + /// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity"). + pub fidelity: f64, + /// Brush / eraser diameter in points. + pub size: f64, + /// Fill new pencil/brush strokes. + pub fill: bool, + /// "Edit selected paths within N pixels". + pub edit_within: f64, + /// "Close paths when ends are within N pixels". + pub close_within: f64, +} + +impl GestureTool { + pub fn new(id: &str) -> Self { + let id: &'static str = match id { + "paintbrush" => "paintbrush", + "blobBrush" => "blobBrush", + "eraser" => "eraser", + "knife" => "knife", + "smooth" => "smooth", + "pathEraser" => "pathEraser", + "join" => "join", + _ => "pencil", + }; + let fidelity = if id == "smooth" { 2.5 } else { 1.5 }; + Self { + id, + points: vec![], + active: false, + began: false, + extend: None, + fidelity, + size: 10.0, + fill: false, + edit_within: 12.0, + close_within: 15.0, + } + } + + fn draws_path(&self) -> bool { + matches!(self.id, "pencil" | "paintbrush") + } + + fn label(&self) -> &'static str { + match self.id { + "paintbrush" => "Paintbrush", + "blobBrush" => "Blob Brush", + "eraser" => "Eraser", + "knife" => "Knife", + "smooth" => "Smooth", + "pathEraser" => "Path Eraser", + "join" => "Join", + _ => "Pencil", + } + } + + /// The selected open path whose end is near `p`. + fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> { + let tol = cx.tol(self.edit_within); + for id in &cx.selection.objects { + let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; + if !cx.doc.is_editable(*id) { + continue; + } + let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue }; + let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); + if p.distance(last) <= tol { + return Some((*id, false)); + } + if p.distance(first) <= tol { + return Some((*id, true)); + } + } + None + } + + /// Parameters of `path.freehand` for the current stroke. + pub fn freehand_params(&self, closed: bool) -> Value { + let mut v = json!({ + "points": points_json(&self.points), + "fidelity": self.fidelity, + "closed": closed, + "style": if self.id == "paintbrush" { "brush" } else { "pencil" }, + "fill": self.fill, + }); + if let Some((id, start)) = self.extend { + v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }}); + } + v + } + + fn finish(&mut self, cx: &ToolContext, m: Mods) -> Vec { + let pts = &self.points; + let has_sel = !cx.selection.is_empty(); + let tol = cx.tol(self.edit_within); + match self.id { + "pencil" | "paintbrush" => { + if !self.began { + return vec![]; + } + let near = pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within); + let closed = m.alt || (self.extend.is_none() && near); + vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit] + } + "blobBrush" => vec![Action::Exec("path.blob".into(), json!({"points": points_json(pts), "size": self.size}))], + "eraser" => vec![Action::Exec("path.eraseRegion".into(), json!({"points": points_json(pts), "size": self.size}))], + "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], + "smooth" if has_sel && pts.len() >= 2 => { + vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))] + } + "pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))], + "join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))], + _ => vec![], + } + } +} + +impl Tool for GestureTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.active + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + self.points = vec![p]; + self.active = true; + self.began = false; + self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None }; + vec![] + } + PointerKind::Drag => { + if !self.active { + return vec![]; + } + if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { + return vec![]; + } + self.points.push(p); + if !self.draws_path() { + return vec![]; + } + let mut out = vec![]; + if !self.began { + self.began = true; + out.push(Action::Begin(self.label().into())); + } + out.push(Action::Preview("path.freehand".into(), self.freehand_params(false))); + out + } + PointerKind::Up => { + if !self.active { + return vec![]; + } + if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { + self.points.push(p); + } + let out = self.finish(cx, ev.mods); + self.active = false; + self.began = false; + self.points.clear(); + self.extend = None; + out + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + match key { + ToolKey::Escape if self.active => { + let began = self.began; + self.active = false; + self.began = false; + self.points.clear(); + if began { vec![Action::Cancel] } else { vec![] } + } + ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => { + self.size = (self.size - 1.0).max(1.0); + vec![] + } + ToolKey::BracketRight if matches!(self.id, "blobBrush" | "eraser") => { + self.size = (self.size + 1.0).min(1000.0); + vec![] + } + _ => vec![], + } + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + let began = self.began; + self.active = false; + self.began = false; + self.points.clear(); + if began { vec![Action::Commit] } else { vec![] } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.active || self.points.len() < 2 || self.began { + return vec![]; + } + let width = match self.id { + "blobBrush" | "eraser" => (self.size * cx.zoom) as f32, + _ => 1.0, + }; + let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK }; + vec![Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + match self.id { + "pencil" | "paintbrush" => { + json!({"fidelity": self.fidelity, "fill": self.fill, "editWithin": self.edit_within, "closeWithin": self.close_within}) + } + "smooth" => json!({"fidelity": self.fidelity}), + "blobBrush" | "eraser" => json!({"size": self.size}), + _ => Value::Null, + } + } + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(0.05, 100.0), + "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), + "fill" => self.fill = v.as_bool().unwrap_or(self.fill), + "editWithin" => self.edit_within = v.as_f64().unwrap_or(self.edit_within).clamp(0.0, 100.0), + "closeWithin" => self.close_within = v.as_f64().unwrap_or(self.close_within).clamp(0.0, 100.0), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { + let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))]; + for &(x, y) in &pts[1..] { + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y))); + } + let l = pts[pts.len() - 1]; + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); + v + } + + #[test] + fn pencil_previews_and_commits() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); + assert!(acts[0].is_empty()); + assert_eq!(acts[1][0], Action::Begin("Pencil".into())); + assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2)); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil")); + assert_eq!(last[1], Action::Commit); + assert!(!t.busy()); + } + + #[test] + fn pencil_alt_closes_and_continues_selected_path() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("paintbrush"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() }); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush")); + // A selected closed rect is not continued; an open path would be (see engine tests). + let mut s2 = Selection::default(); + s2.set([id]); + let cx2 = crate::testutil::cx(&d, &s2, &p); + let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default()); + assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); + } + + #[test] + fn eraser_and_knife_exec_on_release() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts[1].is_empty()); + assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0)); + let mut k = GestureTool::new("knife"); + k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert_eq!(k.overlays(&cx).len(), 1); + let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); + } + + #[test] + fn smooth_needs_selection_and_brackets_resize() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("smooth"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts.iter().all(|a| a.is_empty())); + let mut b = GestureTool::new("blobBrush"); + b.key(&cx, ToolKey::BracketRight, Mods::default()); + assert_eq!(b.options()["size"], 11.0); + } +} diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 5ba41e63..70582aec 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -82,11 +82,15 @@ impl Targets { } let mut out = p; let mut ov = vec![]; - if let Some((x, from, _)) = self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs())) { + if let Some((x, from, _)) = + self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs())) + { out.x = *x; ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false }); } - if let Some((y, from, _)) = self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs())) { + if let Some((y, from, _)) = + self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs())) + { out.y = *y; ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false }); } @@ -101,8 +105,16 @@ impl Targets { let c = r.center(); let xs = [r.x0, c.x, r.x1]; let ys = [r.y0, c.y, r.y1]; - let best_x = xs.iter().flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); - let best_y = ys.iter().flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); + let best_x = xs + .iter() + .flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x))) + .filter(|(d, _, _)| d.abs() <= tol) + .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); + let best_y = ys + .iter() + .flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y))) + .filter(|(d, _, _)| d.abs() <= tol) + .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); let mut d = Vec2::ZERO; let mut ov = vec![]; if let Some((dx, from, x)) = best_x { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c23c4c6c..f63ad78f 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,12 +11,12 @@ pub mod bbox; pub mod catalog; pub mod direct; pub mod draw2; -pub mod xform; pub mod guides; pub mod pen; pub mod select; pub mod shape; pub mod text; +pub mod xform; use drawcraft_color::Paint; use drawcraft_doc::{Document, NodeId, Selection}; @@ -243,7 +243,9 @@ pub fn create(id: &str) -> Box { "pen" => Box::new(pen::PenTool::default()), "type" => Box::new(text::TypeTool::default()), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), - other => draw2::create(other).or_else(|| xform::create(other)).unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), + other => draw2::create(other) + .or_else(|| xform::create(other)) + .unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), } } diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index bab7e2c2..7c1d66a4 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -33,7 +33,18 @@ impl ShapeTool { "lineSegment" => "lineSegment", _ => "rectangle", }; - Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5, guides: vec![] } + Self { + id, + start: None, + last: Point::ZERO, + mods: Mods::default(), + began: false, + sides: 6, + points: 5, + corner_radius: 12.0, + star_ratio: 0.5, + guides: vec![], + } } fn label(&self) -> &'static str { diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index a87ba39d..81ecf463 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,9 +1,91 @@ //! Transform and utility tools (rotate/reflect/scale/shear, free transform, eyedropper, gradient, //! artboard, magic wand, lasso, measure). +//! +//! Like every tool they only emit actions: transforms preview `object.transform` / `object.distort` +//! on the interaction snapshot, the utility tools execute selection / paint / artboard commands. -use crate::Tool; +mod artboard; +mod eyedropper; +mod free; +mod gradient; +mod measure; +mod transform; +mod wand; + +use drawcraft_doc::{Document, NodeId, NodeKind}; +use drawcraft_geom::{BezPath, Point, Rect, Shape}; + +use crate::{Overlay, Tool}; + +pub use artboard::ArtboardTool; +pub use eyedropper::EyedropperTool; +pub use free::{DistortMode, FreeTransformTool}; +pub use gradient::GradientTool; +pub use measure::MeasureTool; +pub use transform::{TransformKind, TransformTool}; +pub use wand::{LassoTool, MagicWandTool}; /// Create a transform/utility tool by id (None = not one of ours). -pub fn create(_id: &str) -> Option> { - None +pub fn create(id: &str) -> Option> { + Some(match id { + "rotate" => Box::new(TransformTool::new(TransformKind::Rotate)), + "reflect" => Box::new(TransformTool::new(TransformKind::Reflect)), + "scale" => Box::new(TransformTool::new(TransformKind::Scale)), + "shear" => Box::new(TransformTool::new(TransformKind::Shear)), + "freeTransform" => Box::new(FreeTransformTool::default()), + "eyedropper" => Box::new(EyedropperTool), + "gradient" => Box::new(GradientTool::default()), + "artboard" => Box::new(ArtboardTool::default()), + "magicWand" => Box::new(MagicWandTool::default()), + "lasso" => Box::new(LassoTool::default()), + "measure" => Box::new(MeasureTool::default()), + _ => return None, + }) +} + +/// Cyan used for the reference point target and tool annotations. +pub const CYAN: [u8; 3] = [0x00, 0xa8, 0xff]; +/// Illustrator's selection blue for bounding boxes. +pub const BLUE: [u8; 3] = [0x4a, 0x7c, 0xff]; + +/// The reference-point target (circle + crosshair) drawn at `p`, `r` in document units. +pub(crate) fn target_overlays(p: Point, r: f64) -> Vec { + let circle = drawcraft_geom::kurbo::Circle::new(p, r * 0.6).to_path(0.05); + vec![ + Overlay::Path { path: circle, color: CYAN, width: 1.0, dashed: false }, + Overlay::Line { a: Point::new(p.x - r, p.y), b: Point::new(p.x + r, p.y), color: CYAN, dashed: false }, + Overlay::Line { a: Point::new(p.x, p.y - r), b: Point::new(p.x, p.y + r), color: CYAN, dashed: false }, + ] +} + +/// Closed polygon path through `pts`. +pub(crate) fn polygon(pts: &[Point], closed: bool) -> BezPath { + let mut bp = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { + bp.move_to(*p); + } else { + bp.line_to(*p); + } + } + if closed && pts.len() > 2 { + bp.close_path(); + } + bp +} + +pub(crate) fn rect_corners(r: Rect) -> [Point; 4] { + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] +} + +/// The node that owns the appearance for a hit leaf (a compound path's child → the compound). +pub(crate) fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId { + match doc.parent_of(leaf) { + Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p, + _ => leaf, + } +} + +pub(crate) fn fmt_pt(v: f64) -> String { + format!("{v:.2} pt") } diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs new file mode 100644 index 00000000..ba36d176 --- /dev/null +++ b/crates/tools/src/xform/artboard.rs @@ -0,0 +1,276 @@ +//! Artboard tool (Shift+O). +//! +//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves +//! it (with its artwork when the `moveArt` option is on, Shift constrains), drag a handle resizes it +//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes +//! the active one and Escape returns to the Selection tool. + +use drawcraft_geom::{Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use super::{BLUE, polygon, rect_corners}; +use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Clone, Copy, Debug)] +enum Drag { + Move { index: usize, start: Point, began: bool }, + Resize { index: usize, handle: Handle, rect: Rect, began: bool }, + Create { start: Point, cur: Point }, +} + +pub struct ArtboardTool { + /// Index of the active artboard. + pub active: usize, + /// Move artwork with the artboard. + pub move_art: bool, + drag: Option, + preview: Option, +} + +impl Default for ArtboardTool { + fn default() -> Self { + Self { active: 0, move_art: true, drag: None, preview: None } + } +} + +fn rect_json(index: usize, r: Rect) -> Value { + json!({ "index": index, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }) +} + +impl ArtboardTool { + fn active_rect(&self, cx: &ToolContext) -> Option { + cx.doc.artboards.get(self.active).map(|a| a.rect) + } +} + +impl Tool for ArtboardTool { + fn id(&self) -> &'static str { + "artboard" + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let m = ev.mods; + match (ev.kind, self.drag) { + (PointerKind::Down, _) => { + if let Some(r) = self.active_rect(cx) + && let Some(handle) = hit_handle(r, p, cx.tol(5.0)) + { + self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, began: false }); + return vec![]; + } + if let Some(i) = (0..cx.doc.artboards.len()).rev().find(|i| cx.doc.artboards[*i].rect.contains(p)) { + self.active = i; + self.drag = Some(Drag::Move { index: i, start: p, began: false }); + } else { + let (p, _) = crate::guides::snap_draw(cx, p, &[]); + self.drag = Some(Drag::Create { start: p, cur: p }); + } + vec![] + } + (PointerKind::Drag, Some(Drag::Move { index, start, began })) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Move Artboard".into())); + } + self.drag = Some(Drag::Move { index, start, began: true }); + let d = move_delta(start, p, m.shift); + self.preview = cx.doc.artboards.get(index).map(|a| a.rect + d); + out.push(Action::Preview("artboard.move".into(), json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art }))); + out + } + (PointerKind::Drag, Some(Drag::Resize { index, handle, rect, began })) => { + let mut out = vec![]; + if !began { + out.push(Action::Begin("Resize Artboard".into())); + } + self.drag = Some(Drag::Resize { index, handle, rect, began: true }); + let nr = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect); + self.preview = Some(nr); + out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr))); + out + } + (PointerKind::Drag, Some(Drag::Create { start, .. })) => { + let (mut cur, _) = crate::guides::snap_draw(cx, p, &[]); + if m.shift { + let d = cur - start; + let s = d.x.abs().max(d.y.abs()); + cur = start + Vec2::new(s.copysign(d.x), s.copysign(d.y)); + } + self.drag = Some(Drag::Create { start, cur }); + vec![] + } + (PointerKind::Up, Some(d)) => { + self.drag = None; + self.preview = None; + match d { + Drag::Move { began: true, .. } | Drag::Resize { began: true, .. } => vec![Action::Commit], + Drag::Create { start, cur } => { + let r = Rect::from_points(start, cur); + if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) { + return vec![]; + } + vec![ + Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })), + Action::Notify("created".into()), + ] + } + _ => vec![], + } + } + (PointerKind::DoubleClick, _) => { + self.drag = None; + match cx.doc.artboards.get(self.active) { + Some(a) => { + let mut v = rect_json(self.active, a.rect); + v["name"] = json!(a.name); + vec![Action::Dialog("artboardOptions".into(), v)] + } + None => vec![], + } + } + _ => vec![], + } + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Delete | ToolKey::Backspace if self.drag.is_none() => { + if cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() { + return vec![]; + } + let i = self.active; + self.active = i.saturating_sub(1); + vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))] + } + ToolKey::Escape => { + let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); + self.drag = None; + self.preview = None; + if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] } + } + _ => vec![], + } + } + + fn notify(&mut self, cx: &ToolContext, what: &str) { + if what == "created" { + self.active = cx.doc.artboards.len().saturating_sub(1); + } + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = vec![]; + if let Some(Drag::Create { start, cur }) = self.drag { + let r = Rect::from_points(start, cur); + o.push(Overlay::Marquee(r)); + o.push(Overlay::Measure { + p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), + text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()), + }); + return o; + } + let Some(ab) = cx.doc.artboards.get(self.active) else { return o }; + let r = self.preview.unwrap_or(ab.rect); + o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true }); + for h in Handle::ALL { + o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }); + } + o.push(Overlay::Label { p: Point::new(r.x0, r.y0 - cx.tol(14.0)), text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE }); + if let Some(Drag::Resize { began: true, .. }) = self.drag { + o.push(Overlay::Measure { + p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)), + text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()), + }); + } + o + } + + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if let Some(r) = self.active_rect(cx) + && let Some(h) = hit_handle(r, p, cx.tol(5.0)) + { + return match h { + Handle::Top | Handle::Bottom => Cursor::ResizeV, + Handle::Left | Handle::Right => Cursor::ResizeH, + Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, + Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw, + }; + } + if cx.doc.artboards.iter().any(|a| a.rect.contains(p)) { Cursor::Move } else { Cursor::Crosshair } + } + + fn options(&self) -> Value { + json!({ "active": self.active, "moveArt": self.move_art }) + } + + fn set_option(&mut self, key: &str, value: &Value) { + match key { + "active" => self.active = value.as_u64().unwrap_or(0) as usize, + "moveArt" => self.move_art = value.as_bool().unwrap_or(true), + _ => {} + } + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); + self.drag = None; + self.preview = None; + if busy { vec![Action::Cancel] } else { vec![] } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn move_resize_create_delete() { + let (mut d, _) = doc_with_rect(); + let mut ab = d.artboards[0].clone(); + ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); + ab.name = "Artboard 2".into(); + d.artboards.push(ab); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Click the second artboard → active; drag moves it. + t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.active, 1); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0)); + assert_eq!(a[0], Action::Begin("Move Artboard".into())); + assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2"))); + // Handle drag resizes. + t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0)); + assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0}))); + t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0)); + // Drag on the pasteboard creates one. + t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0)); + assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_))); + let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); + assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); + assert_eq!(a[1], Action::Notify("created".into())); + // Delete removes the active artboard. + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); + } +} diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs new file mode 100644 index 00000000..f73e6273 --- /dev/null +++ b/crates/tools/src/xform/eyedropper.rs @@ -0,0 +1,101 @@ +//! Eyedropper tool (I). +//! +//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the +//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into +//! the active fill/stroke (`paint.sampleColor`). + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::hit::{HitKind, hit_test}; +use drawcraft_geom::Point; +use serde_json::json; + +use super::paint_owner; +use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; + +pub struct EyedropperTool; + +/// The colour of `paint` at `p` (gradients are sampled along their vector). +pub fn paint_color_at(paint: &Paint, bounds: Option, p: Point) -> Option { + match paint { + Paint::Solid { color, .. } => Some(*color), + Paint::Gradient(g) => { + let geom = g.resolve(bounds?); + let v = geom.end - geom.start; + let t = match g.gradient.kind { + drawcraft_color::GradientKind::Radial => (p - geom.start).hypot() / v.hypot().max(1e-9), + _ => (p - geom.start).dot(v) / v.hypot2().max(1e-9), + }; + Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) + } + _ => None, + } +} + +impl Tool for EyedropperTool { + fn id(&self) -> &'static str { + "eyedropper" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if ev.kind != PointerKind::Down { + return vec![]; + } + let opts = drawcraft_doc::hit::HitOptions { path_only: false, ..cx.hit_options() }; + let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] }; + let src = paint_owner(cx.doc, h.leaf); + let Some(n) = cx.doc.node(src) else { return vec![] }; + if ev.mods.shift { + let (fill, stroke) = match &n.kind { + drawcraft_doc::NodeKind::Text(t) => { + let st = t.runs.first().map(|r| r.style.clone()); + (st.as_ref().map(|s| s.fill.clone()).unwrap_or_default(), st.map(|s| s.stroke).unwrap_or_default()) + } + _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), + }; + let paint = if h.kind == HitKind::Stroke || fill.is_none() { stroke } else { fill }; + return match paint_color_at(&paint, n.geometric_bounds(), ev.pos) { + Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))], + None => vec![], + }; + } + vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))] + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Eyedropper + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn click_copies_appearance_shift_samples_color() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = EyedropperTool; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]); + let shift = Mods { shift: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#ffffff"}))]); + // On the stroke edge → the stroke colour. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000"}))]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty()); + } + + #[test] + fn gradient_sampled_along_vector() { + let g = drawcraft_color::GradientPaint::new(drawcraft_color::Gradient::default()); + let paint = Paint::Gradient(Box::new(g)); + let r = drawcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0); + assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff"); + assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000"); + } +} diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs new file mode 100644 index 00000000..2b794cff --- /dev/null +++ b/crates/tools/src/xform/free.rs @@ -0,0 +1,382 @@ +//! Free Transform tool (E). +//! +//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional, +//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle +//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or +//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the +//! box and preview `object.distort`. + +use drawcraft_geom::{Affine, Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use super::{BLUE, polygon, rect_corners}; +use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; +use crate::select::{matrix_json, selection_bounds}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DistortMode { + /// Scale / rotate / shear / move. + #[default] + Free, + /// Corners move symmetrically along their side. + Perspective, + /// Corners and sides move independently. + Distort, +} + +impl DistortMode { + fn name(self) -> &'static str { + match self { + DistortMode::Free => "free", + DistortMode::Perspective => "perspective", + DistortMode::Distort => "distort", + } + } +} + +#[derive(Clone, Copy, Debug)] +enum Op { + Move, + Scale(Handle), + Shear(Handle), + Rotate, + Corners(Handle, DistortMode), +} + +#[derive(Clone, Copy, Debug)] +struct Drag { + op: Op, + rect: Rect, + start: Point, + began: bool, +} + +#[derive(Default)] +pub struct FreeTransformTool { + pub mode: DistortMode, + /// The on-canvas "Constrain" toggle (acts like holding Shift). + pub constrain: bool, + drag: Option, + /// The current (distorted) quad while dragging corners. + quad: Option<[Point; 4]>, + measure: Option<(Point, String)>, +} + +/// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt). +pub fn shear_for_side(r: Rect, h: Handle, d: Vec2, from_center: bool) -> Affine { + let o = if from_center { r.center() } else { h.opposite().pos(r) }; + let hp = h.pos(r); + let m = match h { + Handle::Top | Handle::Bottom => { + let lever = hp.y - o.y; + let k = if lever.abs() > 1e-9 { d.x / lever } else { 0.0 }; + Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0]) + } + _ => { + let lever = hp.x - o.x; + let k = if lever.abs() > 1e-9 { d.y / lever } else { 0.0 }; + Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0]) + } + }; + Affine::translate(o.to_vec2()) * m * Affine::translate(-o.to_vec2()) +} + +/// Corner index (TL, TR, BR, BL) for a corner handle. +fn corner_index(h: Handle) -> Option { + match h { + Handle::TopLeft => Some(0), + Handle::TopRight => Some(1), + Handle::BottomRight => Some(2), + Handle::BottomLeft => Some(3), + _ => None, + } +} + +/// The distorted quad for dragging handle `h` of `r` by `d`. +pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4] { + let mut q = rect_corners(r); + match (corner_index(h), mode) { + (Some(i), DistortMode::Perspective) => { + // Move along the dominant axis; the partner corner on that side mirrors the move. + if d.x.abs() >= d.y.abs() { + let partner = [1, 0, 3, 2][i]; + q[i].x += d.x; + q[partner].x -= d.x; + } else { + let partner = [3, 2, 1, 0][i]; + q[i].y += d.y; + q[partner].y -= d.y; + } + } + (Some(i), _) => q[i] += d, + (None, _) => { + let (a, b) = match h { + Handle::Top => (0, 1), + Handle::Right => (1, 2), + Handle::Bottom => (2, 3), + _ => (3, 0), + }; + q[a] += d; + q[b] += d; + } + } + q +} + +impl FreeTransformTool { + fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option { + let tol = cx.tol(5.0); + if let Some(h) = hit_handle(r, p, tol) { + let mode = if h.is_corner() && m.cmd && m.alt && m.shift { + DistortMode::Perspective + } else if h.is_corner() && m.cmd { + DistortMode::Distort + } else { + self.mode + }; + return Some(match mode { + DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h), + DistortMode::Free => Op::Scale(h), + DistortMode::Perspective if !h.is_corner() => Op::Scale(h), + mode => Op::Corners(h, mode), + }); + } + if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { + return Some(Op::Rotate); + } + r.contains(p).then_some(Op::Move) + } + + fn preview(&mut self, d: Drag, p: Point, m: Mods) -> Action { + let shift = m.shift || self.constrain; + let delta = p - d.start; + let xf = match d.op { + Op::Corners(h, mode) => { + let q = distort_quad(d.rect, h, delta, mode); + self.quad = Some(q); + self.measure = None; + let corners: Vec = q.iter().map(|c| json!([c.x, c.y])).collect(); + return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] })); + } + Op::Move => { + let v = move_delta(d.start, p, shift); + self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", v.x, v.y))); + Affine::translate(v) + } + Op::Scale(h) => { + let a = scale_for_drag(d.rect, h, p, shift, m.alt); + let nr = a.transform_rect_bbox(d.rect); + self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height()))); + a + } + Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt), + Op::Rotate => { + let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift); + self.measure = Some((p, format!("{:.1}°", -deg))); + a + } + }; + self.quad = Some(rect_corners(d.rect).map(|c| xf * c)); + Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false })) + } +} + +impl Tool for FreeTransformTool { + fn id(&self) -> &'static str { + "freeTransform" + } + + fn busy(&self) -> bool { + self.drag.is_some_and(|d| d.began) + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + self.drag = None; + let Some(r) = selection_bounds(cx) else { return vec![] }; + if let Some(op) = self.classify(cx, r, p, ev.mods) { + self.drag = Some(Drag { op, rect: r, start: p, began: false }); + } + vec![] + } + PointerKind::Drag => { + let Some(mut d) = self.drag else { return vec![] }; + let mut out = vec![]; + if !d.began { + if p.distance(d.start) < cx.tol(2.0) { + return out; + } + d.began = true; + let label = match d.op { + Op::Move => "Move", + Op::Scale(_) => "Scale", + Op::Shear(_) => "Shear", + Op::Rotate => "Rotate", + Op::Corners(..) => "Distort", + }; + out.push(Action::Begin(label.into())); + } + self.drag = Some(d); + out.push(self.preview(d, p, ev.mods)); + out + } + PointerKind::Up => { + let d = self.drag.take(); + self.quad = None; + self.measure = None; + if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] } + } + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.busy() { + self.drag = None; + self.quad = None; + self.measure = None; + return vec![Action::Cancel]; + } + vec![] + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = vec![]; + let quad = match (self.quad, selection_bounds(cx)) { + (Some(q), _) => q, + (None, Some(r)) => rect_corners(r), + _ => return o, + }; + o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false }); + for i in 0..4 { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + o.push(Overlay::Anchor { p: a, color: BLUE, filled: false, size: 6.0 }); + if self.mode != DistortMode::Perspective { + o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 }); + } + } + if let Some((p, t)) = &self.measure { + o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); + } + o + } + + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; + match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { + Some(Op::Rotate) => Cursor::Rotate, + Some(Op::Move) => Cursor::Move, + Some(Op::Scale(h) | Op::Shear(h)) => match h { + Handle::Top | Handle::Bottom => Cursor::ResizeV, + Handle::Left | Handle::Right => Cursor::ResizeH, + Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, + Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw, + }, + Some(Op::Corners(..)) => Cursor::Crosshair, + None => Cursor::Arrow, + } + } + + fn options(&self) -> Value { + json!({ "mode": self.mode.name(), "constrain": self.constrain }) + } + + fn set_option(&mut self, key: &str, value: &Value) { + match key { + "mode" => { + self.mode = match value.as_str() { + Some("perspective") => DistortMode::Perspective, + Some("distort") | Some("freeDistort") => DistortMode::Distort, + _ => DistortMode::Free, + } + } + "constrain" => self.constrain = value.as_bool().unwrap_or(false), + _ => {} + } + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.quad = None; + if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn corner_scales_and_inside_moves() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0)); + assert_eq!(a[0], Action::Begin("Scale".into())); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][0] == 2.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 170.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0)); + } + + #[test] + fn cmd_side_handle_shears() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + let cmd = Mods { cmd: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd)); + assert_eq!(a[0], Action::Begin("Shear".into())); + // Bottom edge fixed, top edge moved 20 right. + let Action::Preview(_, v) = &a[1] else { panic!() }; + let c: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([c[0], c[1], c[2], c[3], c[4], c[5]]); + assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9); + assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9); + } + + #[test] + fn distort_modes_emit_corners() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + t.set_option("mode", &json!("distort")); + assert_eq!(t.options()["mode"], "distort"); + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0)); + assert_eq!(a[0], Action::Begin("Distort".into())); + assert_eq!( + a[1], + Action::Preview( + "object.distort".into(), + json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]}) + ) + ); + t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0)); + let q = distort_quad(Rect::new(0.0, 0.0, 10.0, 10.0), Handle::TopLeft, Vec2::new(2.0, 0.0), DistortMode::Perspective); + assert_eq!(q[0], Point::new(2.0, 0.0)); + assert_eq!(q[1], Point::new(8.0, 0.0)); + assert_eq!(q[2], Point::new(10.0, 10.0)); + } +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs new file mode 100644 index 00000000..87f07d13 --- /dev/null +++ b/crates/tools/src/xform/gradient.rs @@ -0,0 +1,159 @@ +//! Gradient tool (G). +//! +//! Drag across selected objects to set the gradient vector of their fill (a solid fill becomes the +//! default gradient). Shift constrains the angle to 45°. With nothing selected, the drag targets +//! the object under the pointer. The overlay is the gradient annotator: a bar from a round start +//! handle to a square end handle, with a tick for each colour stop. + +use drawcraft_color::Paint; +use drawcraft_doc::hit::hit_test; +use drawcraft_geom::{Point, Vec2}; +use serde_json::{Value, json}; + +use super::paint_owner; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +const BAR: [u8; 3] = [0x20, 0x20, 0x20]; +const LIGHT: [u8; 3] = [0xf0, 0xf0, 0xf0]; + +#[derive(Default)] +pub struct GradientTool { + drag: Option<(Point, Point, bool)>, +} + +/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient fill. +pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec)> { + cx.selection.objects.iter().find_map(|id| { + let n = cx.doc.node(*id)?; + let Paint::Gradient(g) = n.appearance.fill_paint() else { return None }; + let geom = g.resolve(n.geometric_bounds()?); + Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect())) + }) +} + +fn annotator_overlays(cx: &ToolContext, a: Point, b: Point, stops: &[f32]) -> Vec { + let mut o = vec![ + Overlay::Line { a, b, color: BAR, dashed: false }, + Overlay::Line { a: a + Vec2::new(0.0, cx.tol(1.0)), b: b + Vec2::new(0.0, cx.tol(1.0)), color: LIGHT, dashed: false }, + Overlay::Handle { p: a, color: BAR }, + Overlay::Anchor { p: b, color: BAR, filled: true, size: 7.0 }, + ]; + let v = b - a; + let len = v.hypot(); + if len > 1e-9 { + let n = Vec2::new(-v.y, v.x) / len * cx.tol(6.0); + for s in stops { + let p = a + v * *s as f64; + o.push(Overlay::Line { a: p, b: p + n, color: BAR, dashed: false }); + } + } + o +} + +impl Tool for GradientTool { + fn id(&self) -> &'static str { + "gradient" + } + + fn busy(&self) -> bool { + self.drag.is_some_and(|d| d.2) + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + let mut out = vec![]; + if cx.selection.is_empty() { + let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out }; + let id = paint_owner(cx.doc, h.leaf); + out.push(Action::Exec("select.set".into(), json!({ "ids": [id.0] }))); + } + self.drag = Some((p, p, false)); + out + } + PointerKind::Drag => { + let Some((start, _, began)) = self.drag else { return vec![] }; + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Gradient".into())); + } + let end = if ev.mods.shift { start + drawcraft_geom::constrain_angle(p - start, 45.0) } else { p }; + self.drag = Some((start, end, true)); + out.push(Action::Preview("paint.setGradientGeom".into(), json!({ "start": [start.x, start.y], "end": [end.x, end.y] }))); + out + } + PointerKind::Up => match self.drag.take() { + Some((_, _, true)) => vec![Action::Commit], + _ => vec![], + }, + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.busy() { + self.drag = None; + return vec![Action::Cancel]; + } + vec![] + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + match (self.drag, annotator(cx)) { + (_, Some((a, b, stops))) => annotator_overlays(cx, a, b, &stops), + (Some((a, b, true)), None) => annotator_overlays(cx, a, b, &[0.0, 1.0]), + _ => vec![], + } + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + + fn options(&self) -> Value { + Value::Null + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn drag_sets_gradient_vector_and_constrains() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 152.0).with_mods(Mods { shift: true, ..Default::default() })); + assert_eq!(a[0], Action::Begin("Gradient".into())); + let Action::Preview(c, v) = &a[1] else { panic!() }; + assert_eq!(c, "paint.setGradientGeom"); + assert_eq!(v["start"], json!([110.0, 150.0])); + assert!((v["end"][1].as_f64().unwrap() - 150.0).abs() < 1e-9); + // While dragging a solid-filled object the annotator follows the drag. + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Handle { p, .. } if p.x == 110.0))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]); + } + + #[test] + fn empty_selection_targets_hit_object() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); + } +} diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs new file mode 100644 index 00000000..97d90baa --- /dev/null +++ b/crates/tools/src/xform/measure.rs @@ -0,0 +1,108 @@ +//! Measure tool: drag between two points to read distance, angle, dX and dY (Shift constrains to +//! 45°). The result stays on screen (and in the tool options, for agents) until the next drag; the +//! document is never changed. + +use drawcraft_geom::{Point, Vec2}; +use serde_json::{Value, json}; + +use super::{CYAN, fmt_pt}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Default)] +pub struct MeasureTool { + line: Option<(Point, Point)>, + dragging: bool, +} + +/// (distance, angle in degrees counter-clockwise, dx, dy) +pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) { + let d = b - a; + (d.hypot(), (-d.y).atan2(d.x).to_degrees(), d.x, d.y) +} + +impl Tool for MeasureTool { + fn id(&self) -> &'static str { + "measure" + } + + fn busy(&self) -> bool { + self.dragging + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.line = Some((p, p)); + self.dragging = true; + } + PointerKind::Drag | PointerKind::Up if self.dragging => { + if let Some((a, _)) = self.line { + let b = if ev.mods.shift { + a + drawcraft_geom::constrain_angle(ev.pos - a, 45.0) + } else { + crate::guides::snap_draw(cx, ev.pos, &[]).0 + }; + self.line = Some((a, b)); + } + self.dragging = ev.kind == PointerKind::Drag; + } + _ => {} + } + vec![] + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some((a, b)) = self.line else { return vec![] }; + if a == b { + return vec![]; + } + let (dist, ang, dx, dy) = measure(a, b); + vec![ + Overlay::Line { a, b, color: CYAN, dashed: false }, + Overlay::Measure { + p: b + Vec2::new(cx.tol(12.0), cx.tol(12.0)), + text: format!("D: {}\nAngle: {ang:.1}°\ndX: {}\ndY: {}", fmt_pt(dist), fmt_pt(dx), fmt_pt(dy)), + }, + ] + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + + fn options(&self) -> Value { + match self.line { + Some((a, b)) => { + let (d, ang, dx, dy) = measure(a, b); + json!({ "x": a.x, "y": a.y, "distance": d, "angle": ang, "dx": dx, "dy": dy }) + } + None => Value::Null, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn drag_reports_distance_and_angle() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = MeasureTool::default(); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -30.0)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -30.0)).is_empty()); + let o = t.options(); + assert!((o["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); + assert!((o["angle"].as_f64().unwrap() - 53.130102).abs() < 1e-4); + let ov = t.overlays(&cx); + assert!(matches!(&ov[1], Overlay::Measure { text, .. } if text.starts_with("D: 50.00 pt"))); + } +} diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs new file mode 100644 index 00000000..7403bc11 --- /dev/null +++ b/crates/tools/src/xform/transform.rs @@ -0,0 +1,391 @@ +//! Rotate (R), Reflect (O), Scale (S) and Shear tools. +//! +//! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan +//! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt +//! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click +//! or Return opens the dialog too. + +use drawcraft_doc::NodeId; +use drawcraft_geom::{Affine, Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use super::target_overlays; +use crate::bbox::rotate_for_drag; +use crate::select::{matrix_json, selection_bounds}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransformKind { + Rotate, + Reflect, + Scale, + Shear, +} + +impl TransformKind { + pub fn id(self) -> &'static str { + match self { + TransformKind::Rotate => "rotate", + TransformKind::Reflect => "reflect", + TransformKind::Scale => "scale", + TransformKind::Shear => "shear", + } + } + fn label(self) -> &'static str { + match self { + TransformKind::Rotate => "Rotate", + TransformKind::Reflect => "Reflect", + TransformKind::Scale => "Scale", + TransformKind::Shear => "Shear", + } + } + /// Dialog fields (kinds/keys match the UI dialogs). + fn dialog_fields(self, origin: Point) -> Value { + let o = json!([origin.x, origin.y]); + match self { + TransformKind::Rotate => json!({ "angle": 0, "origin": o }), + TransformKind::Reflect => json!({ "axis": "vertical", "origin": o }), + TransformKind::Scale => json!({ "sx": 100, "sy": 100, "origin": o }), + TransformKind::Shear => json!({ "angle": 0, "axis": "horizontal", "origin": o }), + } + } +} + +#[derive(Clone, Copy, Debug)] +struct Drag { + start: Point, + origin: Point, + began: bool, + bounds: Rect, + last: Affine, +} + +pub struct TransformTool { + kind: TransformKind, + /// Custom reference point and the selection it was set for. + origin: Option<(Point, Vec)>, + drag: Option, + measure: Option<(Point, String)>, +} + +fn about(o: Point, a: Affine) -> Affine { + Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) +} + +/// Reflection across the line through the origin at angle `theta` (radians, screen space). +pub fn reflect_matrix(o: Point, theta: f64) -> Affine { + about(o, Affine::rotate(theta) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-theta)) +} + +impl TransformTool { + pub fn new(kind: TransformKind) -> Self { + Self { kind, origin: None, drag: None, measure: None } + } + + /// The effective reference point: the custom one if it belongs to the current selection, + /// otherwise the selection centre. + pub fn reference_point(&self, cx: &ToolContext) -> Option { + if let Some((p, ids)) = &self.origin + && *ids == cx.selection.objects + && !ids.is_empty() + { + return Some(*p); + } + selection_bounds(cx).map(|b| b.center()) + } + + /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. + pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + match self.kind { + TransformKind::Rotate => { + let (a, deg) = rotate_for_drag(o, start, p, shift); + (a, format!("{:.1}°", -deg)) + } + TransformKind::Reflect => { + let mut v = p - o; + if shift { + v = drawcraft_geom::constrain_angle(v, 45.0); + } + let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) }; + (reflect_matrix(o, theta), format!("{:.1}°", drawcraft_geom::normalize_deg(-theta.to_degrees()))) + } + TransformKind::Scale => { + let d0 = start - o; + let d1 = p - o; + let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 }; + let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y)); + if shift { + let l2 = d0.hypot2(); + let k = if l2 > 1e-12 { d1.dot(d0) / l2 } else { 1.0 }; + sx = k; + sy = k; + } + let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; + let (sx, sy) = (clamp(sx), clamp(sy)); + (about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + } + TransformKind::Shear => { + // The shear axis follows the dominant drag direction (Shift keeps it strictly on + // that axis, which this always does). + let d = p - start; + let horizontal = d.x.abs() >= d.y.abs(); + let lever = |a: f64, fallback: f64| if a.abs() > 1e-3 { a } else { fallback.max(1.0) }; + if horizontal { + let k = d.x / lever(start.y - o.y, bounds.height() / 2.0); + (about(o, Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees())) + } else { + let k = d.y / lever(start.x - o.x, bounds.width() / 2.0); + (about(o, Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees())) + } + } + } + } + + fn dialog(&self, o: Point) -> Action { + Action::Dialog(self.kind.id().into(), self.kind.dialog_fields(o)) + } +} + +impl Tool for TransformTool { + fn id(&self) -> &'static str { + self.kind.id() + } + + fn busy(&self) -> bool { + self.drag.is_some_and(|d| d.began) + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + let Some(bounds) = selection_bounds(cx) else { return vec![] }; + if ev.mods.alt { + self.origin = Some((p, cx.selection.objects.clone())); + self.drag = None; + return vec![self.dialog(p)]; + } + let origin = self.reference_point(cx).unwrap_or(bounds.center()); + self.drag = Some(Drag { start: p, origin, began: false, bounds, last: Affine::IDENTITY }); + vec![] + } + PointerKind::Drag => { + let Some(mut d) = self.drag else { return vec![] }; + let mut out = vec![]; + if !d.began { + if p.distance(d.start) < cx.tol(3.0) { + return out; + } + d.began = true; + out.push(Action::Begin(self.kind.label().into())); + } + let (m, text) = self.matrix_for(d.origin, d.start, p, ev.mods.shift, d.bounds); + d.last = m; + self.drag = Some(d); + self.measure = Some((p, text)); + out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": ev.mods.alt }))); + out + } + PointerKind::Up => { + let Some(d) = self.drag.take() else { return vec![] }; + self.measure = None; + if d.began { + // The Alt state at release decides whether the result is a copy. + vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit] + } else { + // A click sets the reference point. + self.origin = Some((d.start, cx.selection.objects.clone())); + vec![] + } + } + PointerKind::DoubleClick => { + self.drag = None; + match self.reference_point(cx) { + Some(o) => vec![self.dialog(o)], + None => vec![], + } + } + PointerKind::Move => vec![], + } + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Escape if self.busy() => { + self.drag = None; + self.measure = None; + vec![Action::Cancel] + } + ToolKey::Enter => self.reference_point(cx).map(|o| vec![self.dialog(o)]).unwrap_or_default(), + _ => vec![], + } + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = vec![]; + let reference = match self.drag { + Some(d) => Some(d.origin), + None => self.reference_point(cx), + }; + if let Some(r) = reference { + o.extend(target_overlays(r, cx.tol(8.0))); + } + if let (Some(d), TransformKind::Reflect | TransformKind::Rotate) = (self.drag, self.kind) + && d.began + && let Some((p, _)) = &self.measure + { + o.push(Overlay::Line { a: d.origin, b: *p, color: super::CYAN, dashed: true }); + } + if let Some((p, t)) = &self.measure { + o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); + } + o + } + + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + if cx.selection.is_empty() { + return Cursor::NotAllowed; + } + match self.kind { + TransformKind::Rotate if self.busy() => Cursor::Rotate, + _ => Cursor::Crosshair, + } + } + + fn options(&self) -> Value { + match &self.origin { + Some((p, _)) => json!({ "origin": [p.x, p.y] }), + None => json!({ "origin": null }), + } + } + + fn set_option(&mut self, key: &str, value: &Value) { + if key == "origin" { + self.origin = value.as_array().and_then(|a| Some((Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?), vec![]))); + } + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + fn alt() -> Mods { + Mods { alt: true, ..Default::default() } + } + + fn matrix(a: &Action) -> Affine { + let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") }; + assert_eq!(c, "object.transform"); + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]) + } + + #[test] + fn rotate_drag_about_center_single_undo() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TransformTool::new(TransformKind::Rotate); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 250.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 250.0)); + assert_eq!(a[0], Action::Begin("Rotate".into())); + // Centre (150,150) is fixed; (250,150) → (150,250). + let m = matrix(&a[1]); + assert!((m * Point::new(150.0, 150.0)).distance(Point::new(150.0, 150.0)) < 1e-9); + assert!((m * Point::new(250.0, 150.0)).distance(Point::new(150.0, 250.0)) < 1e-9); + let up = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 250.0)); + assert_eq!(up.last(), Some(&Action::Commit)); + } + + #[test] + fn click_sets_reference_point_then_drag_uses_it() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)).is_empty()); + assert_eq!(t.reference_point(&cx), Some(Point::new(100.0, 100.0))); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { a, .. } if a.y == 100.0))); + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0)); + let m = matrix(&a[1]); + assert!((m * Point::new(100.0, 100.0)).distance(Point::new(100.0, 100.0)) < 1e-9); + assert!((m * Point::new(200.0, 200.0)).distance(Point::new(300.0, 250.0)) < 1e-9); + } + + #[test] + fn scale_shift_is_uniform() { + let t = TransformTool::new(TransformKind::Scale); + let (m, _) = t.matrix_for(Point::new(150.0, 150.0), Point::new(200.0, 200.0), Point::new(250.0, 240.0), true, Rect::ZERO); + let c = m.as_coeffs(); + assert!((c[0] - c[3]).abs() < 1e-9 && c[0] > 1.0); + } + + #[test] + fn alt_click_opens_dialog_and_alt_release_copies() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TransformTool::new(TransformKind::Reflect); + let a = t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt())); + assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [120.0, 130.0]}))]); + t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt())); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 200.0)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 200.0).with_mods(alt())); + assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true)); + assert_eq!(up[1], Action::Commit); + let mut t = TransformTool::new(TransformKind::Shear); + assert!( + matches!(&t.pointer(&cx, &ev(PointerKind::DoubleClick, 0.0, 0.0))[0], Action::Dialog(k, v) if k == "shear" && v["axis"] == "horizontal") + ); + let mut t = TransformTool::new(TransformKind::Rotate); + assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0)); + } + + #[test] + fn reflect_vertical_axis_mirrors_x() { + let t = TransformTool::new(TransformKind::Reflect); + let o = Point::new(150.0, 150.0); + let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(150.0, 50.0), false, Rect::ZERO); + assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9); + } + + #[test] + fn shear_horizontal_keeps_origin_row() { + let t = TransformTool::new(TransformKind::Shear); + let o = Point::new(150.0, 200.0); + let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(200.0, 100.0), false, Rect::new(100.0, 100.0, 200.0, 200.0)); + assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9); + assert!((m * Point::new(150.0, 100.0)).distance(Point::new(200.0, 100.0)) < 1e-9); + } + + #[test] + fn nothing_selected_does_nothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TransformTool::new(TransformKind::Rotate); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 150.0)).is_empty()); + assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::NotAllowed); + } +} diff --git a/crates/tools/src/xform/wand.rs b/crates/tools/src/xform/wand.rs new file mode 100644 index 00000000..6489e932 --- /dev/null +++ b/crates/tools/src/xform/wand.rs @@ -0,0 +1,280 @@ +//! Magic Wand (Y) and Lasso (Q). +//! +//! Magic Wand: click an object to select every object whose fill colour is within `tolerance` +//! (0..255 RGB distance); Shift adds, Alt subtracts. Lasso: freehand loop selecting anchor points +//! inside it (direct-selection style); Shift adds, Alt subtracts. + +use std::collections::BTreeSet; + +use drawcraft_color::Paint; +use drawcraft_doc::hit::hit_test; +use drawcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use drawcraft_geom::Point; +use serde_json::{Value, json}; + +use super::paint_owner; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; + +pub struct MagicWandTool { + /// RGB distance tolerance, 0..255. + pub tolerance: f64, +} + +impl Default for MagicWandTool { + fn default() -> Self { + Self { tolerance: 32.0 } + } +} + +fn fill_of(n: &drawcraft_doc::Node) -> Paint { + match &n.kind { + NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(), + _ => n.appearance.fill_paint(), + } +} + +/// Are two fills "similar" within `tol` (0..255 Euclidean RGB distance)? Non-solid paints must match exactly. +pub fn similar_fill(a: &Paint, b: &Paint, tol: f64) -> bool { + match (a.color(), b.color()) { + (Some(x), Some(y)) => { + let (x, y) = (x.to_rgb(), y.to_rgb()); + let d: f64 = (0..3).map(|i| ((x[i] - y[i]) as f64 * 255.0).powi(2)).sum::().sqrt(); + d <= tol + 1e-6 + } + _ => a == b, + } +} + +/// Paintable leaves (paths, compounds, text, images) that are visible and editable. +fn paintable(doc: &Document) -> Vec { + let mut out = vec![]; + let mut compounds: Vec = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Compound { .. }) { + compounds.push(n.id); + out.push(n.id); + } else if !n.is_container() { + out.push(n.id); + } + }); + out.retain(|id| { + !doc.parent_of(*id).is_some_and(|p| compounds.contains(&p)) + && doc.is_editable(*id) + && doc.is_visible(*id) + && doc.node(*id).is_some_and(|n| n.visible) + }); + out +} + +impl Tool for MagicWandTool { + fn id(&self) -> &'static str { + "magicWand" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if ev.kind != PointerKind::Down { + return vec![]; + } + let Some(h) = hit_test(cx.doc, ev.pos, cx.hit_options()) else { + return if ev.mods.shift || ev.mods.alt { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + }; + let src = paint_owner(cx.doc, h.leaf); + let Some(reference) = cx.doc.node(src).map(fill_of) else { return vec![] }; + let hits: Vec = paintable(cx.doc) + .into_iter() + .filter(|id| cx.doc.node(*id).is_some_and(|n| similar_fill(&fill_of(n), &reference, self.tolerance))) + .collect(); + if ev.mods.alt { + let keep: Vec = cx.selection.objects.iter().copied().filter(|id| !hits.contains(id)).collect(); + return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&keep) }))]; + } + let cmd = if ev.mods.shift { "select.add" } else { "select.set" }; + vec![Action::Exec(cmd.into(), json!({ "ids": json_ids(&hits) }))] + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + + fn options(&self) -> Value { + json!({ "tolerance": self.tolerance }) + } + + fn set_option(&mut self, key: &str, value: &Value) { + if key == "tolerance" + && let Some(v) = value.as_f64() + { + self.tolerance = v.clamp(0.0, 255.0); + } + } +} + +/// Even-odd point-in-polygon. +pub fn point_in_polygon(poly: &[Point], p: Point) -> bool { + let mut inside = false; + let n = poly.len(); + if n < 3 { + return false; + } + let mut j = n - 1; + for i in 0..n { + let (a, b) = (poly[i], poly[j]); + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { + inside = !inside; + } + j = i; + } + inside +} + +#[derive(Default)] +pub struct LassoTool { + points: Vec, + active: bool, +} + +fn anchors_json(v: &BTreeSet) -> Value { + Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect()) +} + +impl Tool for LassoTool { + fn id(&self) -> &'static str { + "lasso" + } + + fn busy(&self) -> bool { + self.active + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + self.points = vec![p]; + self.active = true; + vec![] + } + PointerKind::Drag if self.active => { + if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) { + self.points.push(p); + } + vec![] + } + PointerKind::Up if self.active => { + self.active = false; + let poly = std::mem::take(&mut self.points); + let (add, sub) = (ev.mods.shift, ev.mods.alt); + if poly.len() < 3 { + return if add || sub { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + } + // Anchors inside the loop for every editable path. + let mut hits: Vec<(NodeId, BTreeSet)> = vec![]; + cx.doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + let v: BTreeSet = + path.anchors().filter(|(_, _, a)| point_in_polygon(&poly, a.p)).map(|(s, i, _)| (s, i)).collect(); + if !v.is_empty() { + hits.push((n.id, v)); + } + } + }); + hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id)); + if sub { + // Current anchor selection minus the lassoed anchors. + let mut items = vec![]; + for id in &cx.selection.objects { + let Some(path) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; + let cur: BTreeSet = match cx.selection.partial(*id) { + Some(s) => s.clone(), + None => path.anchors().map(|(s, i, _)| (s, i)).collect(), + }; + let remove = hits.iter().find(|(h, _)| h == id).map(|(_, v)| v.clone()).unwrap_or_default(); + let rest: BTreeSet = cur.difference(&remove).copied().collect(); + if !rest.is_empty() { + items.push(json!({ "id": id.0, "anchors": anchors_json(&rest) })); + } + } + return vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": false }))]; + } + let items: Vec = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect(); + vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": add }))] + } + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape { + self.active = false; + self.points.clear(); + } + vec![] + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + if !self.active || self.points.len() < 2 { + return vec![]; + } + vec![Overlay::Path { path: super::polygon(&self.points, false), color: [0x40, 0x40, 0x40], width: 1.0, dashed: true }] + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_color::Color; + use drawcraft_doc::{Appearance, Node, Selection}; + use drawcraft_geom::{Rect, shapes}; + + fn add_rect(d: &mut Document, r: Rect, fill: Color) -> NodeId { + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 1.0))).unwrap(); + id + } + + #[test] + fn magic_wand_selects_similar_fills() { + let (mut d, a) = doc_with_rect(); // white fill + let b = add_rect(&mut d, Rect::new(300.0, 300.0, 350.0, 350.0), Color::rgb8(250, 250, 250)); + let c = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0), Color::rgb8(255, 0, 0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = MagicWandTool::default(); + let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0, b.0]}))]); + t.set_option("tolerance", &json!(0)); + let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]); + let shift = Mods { shift: true, ..Default::default() }; + let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 420.0, 420.0).with_mods(shift)); + assert_eq!(r, vec![Action::Exec("select.add".into(), json!({"ids": [c.0]}))]); + } + + #[test] + fn lasso_selects_anchors_inside() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LassoTool::default(); + // A triangle around the top-left corner (100,100) only. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 80.0, 80.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 80.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 140.0)); + assert_eq!(t.overlays(&cx).len(), 1); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 140.0)); + let Action::Exec(c, v) = &a[0] else { panic!() }; + assert_eq!(c, "select.anchorsMany"); + assert_eq!(v["items"][0]["id"], id.0); + assert_eq!(v["items"][0]["anchors"].as_array().unwrap().len(), 1); + assert_eq!(v["add"], false); + assert!(point_in_polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], Point::new(2.0, 2.0))); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d0634324..d5bc96e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -43,8 +43,13 @@ impl Xf { #[derive(Clone, Copy, Debug, PartialEq)] enum Drag { Tool, - Pan { start: Pos2, center: Point }, - ZoomBox { start: Pos2 }, + Pan { + start: Pos2, + center: Point, + }, + ZoomBox { + start: Pos2, + }, /// Cmd held: temporary selection tool; restore this tool on release. TempSelect, } @@ -70,7 +75,9 @@ pub fn fit(app: &mut DrawcraftApp, how: &str) { }; let Some(st) = app.session.active() else { return }; let target = match how { - "view.fitAll" => st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)), + "view.fitAll" => { + st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) + } _ => st.doc.artboards.first().map(|a| a.rect), }; let Some(target) = target else { return }; @@ -112,8 +119,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { if app.ui.view.artboards && !app.ui.view.outline { for ab in &doc.artboards { let r = xf.rect_to_screen(ab.rect); - painter.rect_filled(r.translate(vec2(2.0, 2.0)).expand(1.0), 1.0, Color32::from_black_alpha(90)); - painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(110)); + // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). + painter.rect_filled(r.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(26)); + painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(52)); if app.ui.view.transparency_grid { checker(&painter, r); } else { @@ -132,7 +140,18 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { // Artwork raster. let ppp = ui.ctx().pixels_per_point(); let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); - let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] }; + let key = CacheKey { + doc: app.session.active_index().unwrap_or(0), + revision: st.revision, + zoom: v.zoom, + cx: v.center.x, + cy: v.center.y, + w, + h, + outline: app.ui.view.outline, + ppp, + hidden: vec![], + }; if !app.canvas.worker_started { app.canvas.worker_started = true; if std::env::var_os("DRAWCRAFT_SYNC_RENDER").is_none() { @@ -177,7 +196,13 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside); if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 { - painter.text(r.left_top() - vec2(0.0, 4.0), egui::Align2::LEFT_BOTTOM, format!("{:02} - {}", i + 1, ab.name), egui::FontId::proportional(11.0), t.text_dim); + painter.text( + r.left_top() - vec2(0.0, 4.0), + egui::Align2::LEFT_BOTTOM, + format!("{:02} - {}", i + 1, ab.name), + egui::FontId::proportional(11.0), + t.text_dim, + ); } } if app.ui.view.guides { @@ -205,6 +230,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, &t); } + if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { + task_bar(app, ui, &xf); + } // Cursor. if resp.hovered() { let m = ui.input(|i| i.modifiers); @@ -272,7 +300,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta())); + let (pointer, m, space, scroll, zoom_delta) = + ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta())); let v = *app.view().unwrap_or(&View::default()); let xf = Xf { rect, zoom: v.zoom, center: v.center }; let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); @@ -480,7 +509,13 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) let sx = xf.to_screen(Point::new(x, 0.0)).x; let is_major = ((x / step).round() * step - x).abs() < minor * 0.01; let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01; - let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 }; + let len = if is_major { + RULER + } else if is_mid { + 7.0 + } else { + 4.0 + }; clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); if is_major { clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick); @@ -495,13 +530,25 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) let sy = xf.to_screen(Point::new(0.0, y)).y; let is_major = ((y / step).round() * step - y).abs() < minor * 0.01; let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01; - let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 }; + let len = if is_major { + RULER + } else if is_mid { + 7.0 + } else { + 4.0 + }; clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); if is_major { // Vertical labels read top-to-bottom, one digit per line like Illustrator. let s = format!("{}", y.round() as i64); for (k, ch) in s.chars().enumerate() { - clip_left.text(pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, ch.to_string(), font.clone(), t.ruler_tick); + clip_left.text( + pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), + egui::Align2::LEFT_TOP, + ch.to_string(), + font.clone(), + t.ruler_tick, + ); } } y += minor; @@ -585,6 +632,11 @@ fn hover_highlight(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { } } +/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1). +fn selected_anchor(c: Color32) -> Color32 { + if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c } +} + fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { let r = egui::Rect::from_center_size(c, vec2(size, size)); if filled { @@ -619,11 +671,17 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { if h.distance(a.p) > 1e-6 { let hp = xf.to_screen(h); p.line_segment([sp, hp], Stroke::new(1.0, color)); - p.circle_filled(hp, 2.8, color); + p.circle_filled(hp, 2.75, color); } } } - anchor_square(p, sp, color, sel, if partial.is_some() || direct { 6.0 } else { 4.5 }); + anchor_square( + p, + sp, + if sel && partial.is_some() { selected_anchor(color) } else { color }, + sel, + if partial.is_some() || direct { 5.0 } else { 4.0 }, + ); } }); // Text: baseline marker. @@ -643,16 +701,14 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); for h in drawcraft_tools::bbox::Handle::ALL { let c = xf.to_screen(h.pos(b)); - let hr = egui::Rect::from_center_size(c, vec2(7.0, 7.0)); + let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0)); p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } - // Centre point. - let c = r.center(); - p.rect_filled(egui::Rect::from_center_size(c, vec2(3.0, 3.0)), 0.0, color); // Live corner widgets on single live rectangles. if st.selection.len() == 1 - && let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = st.doc.node(st.selection.objects[0]).map(|n| &n.kind) + && let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = + st.doc.node(st.selection.objects[0]).map(|n| &n.kind) && r.width() > 40.0 && r.height() > 40.0 { @@ -763,7 +819,6 @@ fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) { }); } - fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); } @@ -775,3 +830,86 @@ fn upload(app: &mut DrawcraftApp, ctx: &egui::Context, img: &drawcraft_render::R None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)), } } + +/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. +fn task_bar(app: &mut DrawcraftApp, ui: &mut Ui, xf: &Xf) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + if st.selection.is_empty() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { + return; + } + let Some(b) = st.doc.bounds_of(&st.selection.objects, true) else { return }; + let n = st.selection.len(); + let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned(); + let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. })); + let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_))); + let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command) + if n > 1 { + items.push(("Group", "group", "object.group")); + items.push(("Unite", "squares-unite", "object.pathfinder.unite")); + } else if is_group { + items.push(("Ungroup", "ungroup", "object.ungroup")); + items.push(("Isolate", "square-dashed", "object.isolate")); + } else if is_text { + items.push(("Create Outlines", "type", "type.createOutlines")); + } else { + items.push(("Offset Path", "square-dashed", "object.path.offsetPath")); + items.push(("Simplify", "spline", "object.path.simplify")); + } + items.push(("Duplicate", "copy", "edit.duplicate")); + let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); + let anchor = xf.to_screen(Point::new(b.center().x, b.y1)); + let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let x = (anchor.x - est_w / 2.0).clamp(xf.rect.left() + 8.0, (xf.rect.right() - est_w - 8.0).max(xf.rect.left() + 8.0)); + let y = (anchor.y + 28.0).min(xf.rect.bottom() - 56.0); + let mut run: Option = None; + egui::Area::new(egui::Id::new("task-bar")).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.tool_active)) + .corner_radius(CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (g, _) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::hover()); + ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), t.button_border); + for (label, icon, cmd) in &items { + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); + let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside); + crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); + ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); + if resp.clicked() { + run = Some(cmd.to_string()); + } + } + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill); + ui.painter().rect_stroke( + egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), + 0.0, + Stroke::new(1.0, t.button_border), + StrokeKind::Outside, + ); + if resp.on_hover_text("Fill").clicked() { + app.session.fill_active = true; + app.ui.open_panel = Some("swatches".into()); + } + if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() { + run = Some("object.lock".into()); + } + if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() { + run = Some("window.taskBar".into()); + } + }); + }); + }); + if let Some(c) = run { + crate::menus::invoke(app, &c, json!({})); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e3ce1ee5..7a05ab3a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -13,7 +13,7 @@ use crate::{DrawcraftApp, icons, menus}; pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let left = if app.integrated_titlebar { 78 } else { 8 }; - egui::Panel::top("app_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 })).show(ui, |ui| { + egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { ui.horizontal_centered(|ui| { // Brand mark: a small rounded square with "Dc". let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); @@ -65,110 +65,117 @@ fn chip_button(ui: &mut Ui, paint: &Paint, stroke_style: bool, tip: &str) -> egu /// The Control bar (Window → Control), context-sensitive like Illustrator's. pub fn control_bar(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("control_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { - ui.horizontal_centered(|ui| { - // Gripper - let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); - for i in 0..5 { - ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); - } - let Some(st) = app.session.active() else { - ui.label(egui::RichText::new("No Document").color(t.text_dim)); - return; - }; - let sel = st.selection.objects.clone(); - let units = st.doc.units; - let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); - let anchor_mode = !st.selection.anchors.is_empty(); - let label = match (&first, sel.len()) { - (None, _) => "No Selection".to_string(), - (_, n) if n > 1 => "Mixed Objects".to_string(), - (Some(_), _) if anchor_mode => "Anchor Point".to_string(), - (Some(n), _) => match &n.kind { - NodeKind::Text(_) => "Type".into(), - NodeKind::Image(_) => "Embedded".into(), - NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(), - _ => n.kind_label().to_string(), - }, - }; - ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); - ui.add_space(6.0); - let (fill, stroke, weight, opacity) = match &first { - Some(n) => (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity), - None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0), - }; - if chip_button(ui, &fill, false, "Fill").clicked() { - app.run("paint.toggleActive", json!({})).ok(); - app.session.fill_active = true; - app.ui.open_panel = Some("swatches".into()); - } - if chip_button(ui, &stroke, true, "Stroke").clicked() { - app.session.fill_active = false; - app.ui.open_panel = Some("swatches".into()); - } - if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("stroke".into()); - } - if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) { - app.run("stroke.set", json!({"weight": w})).ok(); - } - ui.add_space(4.0); - ui.separator(); - if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("transparency".into()); - } - if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) - && !sel.is_empty() - { - app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); - } - ui.separator(); - if sel.is_empty() { - if widgets::flat_button(ui, "Document Setup", 112.0).clicked() { - app.run("file.documentSetup", json!({})).ok(); + egui::Panel::top("control_bar") + .exact_size(34.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + // Gripper + let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); + for i in 0..5 { + ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); } - if widgets::flat_button(ui, "Preferences", 90.0).clicked() { - app.run("edit.preferences", json!({})).ok(); - } - return; - } - // Align buttons. - for (icon, tip, p) in [ - ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), - ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), - ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), - ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), - ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), - ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), - ] { - if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { - let mut p = p; - if sel.len() == 1 { - p["to"] = json!("artboard"); + let Some(st) = app.session.active() else { + ui.label(egui::RichText::new("No Document").color(t.text_dim)); + return; + }; + let sel = st.selection.objects.clone(); + let units = st.doc.units; + let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); + let anchor_mode = !st.selection.anchors.is_empty(); + let label = match (&first, sel.len()) { + (None, _) => "No Selection".to_string(), + (_, n) if n > 1 => "Mixed Objects".to_string(), + (Some(_), _) if anchor_mode => "Anchor Point".to_string(), + (Some(n), _) => match &n.kind { + NodeKind::Text(_) => "Type".into(), + NodeKind::Image(_) => "Embedded".into(), + NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(), + _ => n.kind_label().to_string(), + }, + }; + ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + ui.add_space(6.0); + let (fill, stroke, weight, opacity) = match &first { + Some(n) => { + (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity) } - app.run("object.align", p).ok(); + None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0), + }; + if chip_button(ui, &fill, false, "Fill").clicked() { + app.run("paint.toggleActive", json!({})).ok(); + app.session.fill_active = true; + app.ui.open_panel = Some("swatches".into()); } - } - ui.separator(); - // Transform fields. - let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false)); - if let Some(b) = b { - for (k, lbl, v) in [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] { - ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); - if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) { - app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); + if chip_button(ui, &stroke, true, "Stroke").clicked() { + app.session.fill_active = false; + app.ui.open_panel = Some("swatches".into()); + } + if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("stroke".into()); + } + if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) { + app.run("stroke.set", json!({"weight": w})).ok(); + } + ui.add_space(4.0); + ui.separator(); + if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("transparency".into()); + } + if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) + && !sel.is_empty() + { + app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); + } + ui.separator(); + if sel.is_empty() { + if widgets::flat_button(ui, "Document Setup", 112.0).clicked() { + app.run("file.documentSetup", json!({})).ok(); + } + if widgets::flat_button(ui, "Preferences", 90.0).clicked() { + app.run("edit.preferences", json!({})).ok(); + } + return; + } + // Align buttons. + for (icon, tip, p) in [ + ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), + ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), + ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), + ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), + ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), + ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), + ] { + if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { + let mut p = p; + if sel.len() == 1 { + p["to"] = json!("artboard"); + } + app.run("object.align", p).ok(); } } - } + ui.separator(); + // Transform fields. + let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false)); + if let Some(b) = b { + for (k, lbl, v) in + [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] + { + ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); + if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) { + app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); + } + } + } + }); }); - }); } /// Document tab strip: "Name* @ 66.67% (RGB/Preview)". pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 35.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); let mut x = strip.left(); let mut activate = None; @@ -178,23 +185,23 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); let mode = if d.doc.color_mode == drawcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; let vm = if app.ui.view.outline { "Outline" } else { "Preview" }; - let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom)); - let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(12.0), if Some(i) == active { t.text } else { t.text_dim }); - let w = galley.size().x + 44.0; - let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height())); + let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %")); + let is_active = Some(i) == active; + let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim }); + let w = galley.size().x + 50.0; + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0)); let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click()); - if Some(i) == active { + if is_active { ui.painter().rect_filled(r, 0.0, t.panel); } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5)); - } - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - let xr = egui::Rect::from_center_size(egui::pos2(r.right() - 15.0, r.center().y), vec2(14.0, 14.0)); - let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click()); - if xresp.hovered() || Some(i) == active || resp.hovered() { - icons::paint(ui, "x", xr.shrink(1.5), if xresp.hovered() { t.text } else { t.text_dim }); + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); } + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border)); + // × at the left like Illustrator. + let xr = egui::Rect::from_center_size(egui::pos2(r.left() + 16.0, r.center().y), vec2(12.0, 12.0)); + let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click()); + icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); + ui.painter().galley(egui::pos2(r.left() + 32.0, r.center().y - galley.size().y / 2.0), galley, t.text); if xresp.clicked() { close = Some(i); } else if resp.clicked() { @@ -204,7 +211,9 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { } if let Some(i) = close { app.session.close_document(i); - app.views.remove(i.min(app.views.len().saturating_sub(1))); + if i < app.views.len() { + app.views.remove(i); + } app.sync_views(); } else if let Some(i) = activate { app.session.set_active(i); @@ -213,13 +222,20 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { if let Some(st) = app.session.active() && let Some(iso) = st.isolation { - let crumbs: Vec = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + let crumbs: Vec = + st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.panel); let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click()); icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); - ui.painter().text(bar.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, crumbs.join(" › "), egui::FontId::proportional(12.0), t.text); + ui.painter().text( + bar.left_center() + vec2(30.0, 0.0), + egui::Align2::LEFT_CENTER, + crumbs.join(" › "), + egui::FontId::proportional(12.0), + t.text, + ); if bresp.clicked() { app.run("object.exitIsolation", json!({})).ok(); } @@ -228,54 +244,171 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { pub fn status_bar(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { - ui.horizontal_centered(|ui| { - let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); - egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(ui, |ui| { - for z in ZOOM_STOPS.iter().rev() { - if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() { - app.run("view.setZoom", json!({"zoom": z})).ok(); - } + hint_bar(app, ui); + egui::Panel::bottom("status_bar") + .exact_size(24.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); + egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui( + ui, + |ui| { + for z in ZOOM_STOPS.iter().rev() { + if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() { + app.run("view.setZoom", json!({"zoom": z})).ok(); + } + } + ui.separator(); + if ui.selectable_label(false, "Fit on Screen").clicked() { + app.run("view.fitArtboard", json!({})).ok(); + } + if ui.selectable_label(false, "Fit All").clicked() { + app.run("view.fitAll", json!({})).ok(); + } + }, + ); + let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); + egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui( + ui, + |ui| { + for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] { + if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked() + && let Some(v) = app.view_mut() + { + v.rotation = a; + } + } + }, + ); + ui.separator(); + let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); + for icon in ["chevrons-left", "chevron-left"] { + widgets::icon_button(ui, icon, "", false, 18.0); + } + ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5)); + for icon in ["chevron-right", "chevrons-right"] { + widgets::icon_button(ui, icon, "", false, 18.0); } ui.separator(); - if ui.selectable_label(false, "Fit on Screen").clicked() { - app.run("view.fitArtboard", json!({})).ok(); - } - if ui.selectable_label(false, "Fit All").clicked() { - app.run("view.fitAll", json!({})).ok(); - } - }); - let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); - egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(ui, |ui| { - for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] { - if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked() - && let Some(v) = app.view_mut() - { - v.rotation = a; + let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or(""); + ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(format!( + "render {:.1} ms · ui {:.1} ms · {:.0} fps", + app.perf.render_ms, app.perf.frame_ms, app.perf.fps + )) + .size(10.5) + .color(t.text_disabled), + ); + if let Some(p) = app.hover_doc { + ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim)); } - } - }); - ui.separator(); - let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); - for icon in ["chevrons-left", "chevron-left"] { - widgets::icon_button(ui, icon, "", false, 18.0); - } - ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5)); - for icon in ["chevron-right", "chevrons-right"] { - widgets::icon_button(ui, icon, "", false, 18.0); - } - ui.separator(); - let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(format!("render {:.1} ms · ui {:.1} ms · {:.0} fps", app.perf.render_ms, app.perf.frame_ms, app.perf.fps)).size(10.5).color(t.text_disabled)); - if let Some(p) = app.hover_doc { - ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim)); - } - if !app.ui.status.is_empty() { - ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text)); - } + if !app.ui.status.is_empty() { + ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text)); + } + }); + }); + }); +} + +/// Contextual hint for the active tool: segments of (text, bold). +fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { + match tool { + "selection" => &[ + ("Click", true), + (" the object to select | ", false), + ("Shift+Click", true), + (" to select multiple objects | ", false), + ("Option+Drag", true), + (" the object to duplicate", false), + ], + "directSelection" => &[ + ("Click", true), + (" an anchor or path segment to select it | ", false), + ("Drag", true), + (" to move | ", false), + ("Shift+Click", true), + (" to add", false), + ], + "pen" => &[ + ("Click", true), + (" to add a corner point | ", false), + ("Drag", true), + (" to add a smooth point | ", false), + ("Click the first point", true), + (" to close | ", false), + ("Enter", true), + (" to finish", false), + ], + "curvature" => &[ + ("Click", true), + (" to add smooth points | ", false), + ("Double-click", true), + (" to toggle corner | ", false), + ("Esc", true), + (" to finish", false), + ], + "type" => &[ + ("Click", true), + (" to create point type | ", false), + ("Drag", true), + (" to create area type | ", false), + ("Esc", true), + (" to exit editing", false), + ], + "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" => &[ + ("Drag", true), + (" to draw | ", false), + ("Shift+Drag", true), + (" to constrain proportions | ", false), + ("Option+Drag", true), + (" from center | ", false), + ("Click", true), + (" for exact size", false), + ], + "rotate" | "reflect" | "scale" | "shear" => &[ + ("Click", true), + (" to set the reference point | ", false), + ("Drag", true), + (" to transform | ", false), + ("Option+Click", true), + (" for exact values", false), + ], + "hand" => &[("Drag", true), (" to pan the view", false)], + "zoom" => &[ + ("Click", true), + (" to zoom in | ", false), + ("Option+Click", true), + (" to zoom out | ", false), + ("Drag", true), + (" to zoom into an area", false), + ], + "eyedropper" => &[("Click", true), (" an object to copy its appearance | ", false), ("Shift+Click", true), (" to sample a color", false)], + "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], + "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], + _ => &[("Press ", false), ("Cmd+Shift+/", true), (" to search every command", false)], + } +} + +pub fn hint_bar(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("hint_bar") + .exact_size(28.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text)); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); + ui.add_space(6.0); + let mut job = egui::text::LayoutJob::default(); + for (txt, bold) in hint_for(app.session.tool_id()) { + let font = if *bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; + job.append(txt, 0.0, egui::TextFormat { font_id: font, color: if *bold { t.text_strong } else { t.text }, ..Default::default() }); + } + ui.label(job); }); }); - }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3d7c1ba8..dfdb8165 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -144,7 +144,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let m = egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }; + let m = egui::Modifiers { + alt: b("alt"), + ctrl: b("ctrl"), + shift: b("shift"), + mac_cmd: b("cmd") && cfg!(target_os = "macos"), + command: b("cmd"), + }; app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); if let Some(t) = s("text") { @@ -166,7 +172,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(pn) = s("panel") { r = app.run("window.panel", json!({"panel": pn})); } - for (k, flag) in [("rulers", "view.rulers"), ("outline", "view.outline"), ("grid", "view.grid"), ("smartGuides", "view.smartGuides"), ("boundingBox", "view.boundingBox")] { + for (k, flag) in [ + ("rulers", "view.rulers"), + ("outline", "view.outline"), + ("grid", "view.grid"), + ("smartGuides", "view.smartGuides"), + ("boundingBox", "view.boundingBox"), + ] { if let Some(want) = p.get(k).and_then(Value::as_bool) { let cur = match k { "rulers" => app.ui.view.rulers, @@ -236,7 +248,10 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => { let f = s("format").unwrap_or("png"); - wrap(crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))) + wrap( + crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)) + .map(|p| json!({"path": p})), + ) } "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f77d0360..d63823df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,11 +17,34 @@ pub fn open_tool_dialog(app: &mut DrawcraftApp, kind: &str, p: Value) { "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), + "rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => { + let mut base = match kind { + "rotate" => json!({"angle": 0}), + "reflect" => json!({"axis": "vertical"}), + "scale" => json!({"sx": 100, "sy": 100, "uniform": true}), + "shear" => json!({"angle": 0, "axis": "horizontal"}), + _ => json!({}), + }; + if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) { + for (k, v) in o { + b.insert(k.clone(), v.clone()); + } + } + Dialog::new(kind, base) + } _ => return, }; app.ui.dialog = Some(d); } +/// Pass a tool-chosen reference point (origin) through to transform commands. +fn origin_params(d: &Dialog, mut p: Value) -> Value { + if let Some(o) = d.fields.get("origin") { + p["origin"] = o.clone(); + } + p +} + fn title(kind: &str) -> &'static str { match kind { "newDocument" => "New Document", @@ -42,6 +65,7 @@ fn title(kind: &str) -> &'static str { "splitIntoGrid" => "Split Into Grid", "documentSetup" => "Document Setup", "preferences" => "Preferences", + "artboardOptions" => "Artboard Options", "allTools" => "All Tools", _ => "Dialog", } @@ -68,14 +92,23 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result { app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) } "move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})), - "rotate" => app.run("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})), + "rotate" => app.run("object.rotate", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "copy": copy}))), "scale" => { let sx = d.f64("sx", 100.0); let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; - app.run("object.scale", json!({"sx": sx, "sy": sy, "copy": copy})) + app.run("object.scale", origin_params(&d, json!({"sx": sx, "sy": sy, "copy": copy}))) + } + "reflect" => app.run("object.reflect", origin_params(&d, json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy}))), + "shear" => app.run("object.shear", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy}))), + "artboardOptions" => { + let mut p = serde_json::Value::Object(d.fields.clone()); + for k in ["x", "y", "width", "height"] { + if d.fields.contains_key(k) { + p[k] = json!(d.f64(k, 0.0)); + } + } + app.run("artboard.setProps", p) } - "reflect" => app.run("object.reflect", json!({"axis": d.str("axis"), "copy": copy})), - "shear" => app.run("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})), "average" => app.run("path.average", json!({"axis": d.str("axis")})), "offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})), "simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})), @@ -96,7 +129,12 @@ fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).color(t.text_dim)); let mut s = d.str(key); - let r = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(egui::CornerRadius::same(3)).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); + let r = egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(3)) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); } @@ -113,11 +151,16 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { if app.ui.about { let mut open = true; - egui::Window::new("About DrawCraft").collapsible(false).resizable(false).open(&mut open).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0))); - ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); - ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft."); - }); + egui::Window::new("About DrawCraft") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0))); + ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); + ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft."); + }); app.ui.about = open; } let Some(mut d) = app.ui.dialog.clone() else { return }; @@ -158,7 +201,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in keys { - if k == "x" || k == "y" || v.is_boolean() { + if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() { continue; } field(ui, &mut d, &k, &humanize(&k)); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7130bde6..045f4eb9 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -11,52 +11,66 @@ const ICON_COL: f32 = 38.0; pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - // Collapsed icon column (rightmost). - egui::Panel::right("icon_column").resizable(false).exact_size(ICON_COL).frame(egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::symmetric(3, 6)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { - if gi > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); + // Main tabbed group. + egui::Panel::right("dock") + .resizable(true) + .default_size(300.0) + .size_range(230.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + // Tab strip. + let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); + ui.painter().rect_filled(hdr, 0.0, t.tab_strip); + icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text); + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); + ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); + let mut x = strip.left(); + for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] { + let active = app.ui.dock_tab == tab; + let galley = ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim }); + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0)); + let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click()); + if active { + ui.painter().rect_filled(r, 0.0, t.panel); + } + ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); + if resp.clicked() { + app.ui.dock_tab = tab; + } + x = r.right(); } - for id in group.iter() { - let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; - let open = app.ui.open_panel.as_deref() == Some(*id); - if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim); + egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { + DockTab::Properties => { + egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); + } + DockTab::Layers => panels::layers::show(app, ui), + DockTab::Libraries => panels::libraries(app, ui), + }); + }); + // Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). + egui::Panel::right("icon_column") + .resizable(false) + .exact_size(ICON_COL) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { + if gi > 0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); + } + for id in group.iter() { + let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; + let open = app.ui.open_panel.as_deref() == Some(*id); + if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } } } - } - }); - // Main tabbed group. - egui::Panel::right("dock").resizable(true).default_size(286.0).size_range(230.0..=520.0).frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { - // Tab strip. - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - let mut x = strip.left(); - for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] { - let active = app.ui.dock_tab == tab; - let galley = ui.painter().layout_no_wrap(label.to_string(), if active { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }, if active { t.text } else { t.text_dim }); - let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height())); - let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click()); - if active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - if resp.clicked() { - app.ui.dock_tab = tab; - } - x = r.right(); - } - icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim); - egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { - DockTab::Properties => { - egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); - } - DockTab::Layers => panels::layers::show(app, ui), - DockTab::Libraries => panels::libraries(app, ui), }); - }); } /// An icon panel popped out next to the icon column. @@ -65,14 +79,17 @@ pub fn floating_panel(app: &mut DrawcraftApp, ctx: &egui::Context) { let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return }; let t = Tokens::get(ctx); let screen = ctx.content_rect(); - let x = screen.right() - ICON_COL - 286.0 - 262.0; + let x = screen.right() - ICON_COL - 300.0 - 262.0; let mut open = true; egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, 110.0)).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { ui.set_width(256.0); let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); - let tab = egui::Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0)); + let tab = egui::Rect::from_min_size( + strip.min, + vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0), + ); ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, *label, theme::semibold(12.0), t.text); let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ff63e749..d4c2d52f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -97,7 +97,13 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-rotate-view" => "dc-rotate-view", "tool-print-tiling" => "printer", "tool-zoom" => "zoom-in", - other => if exists(other) { table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") } else { "square-dashed" }, + other => { + if exists(other) { + table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") + } else { + "square-dashed" + } + } } } @@ -117,4 +123,3 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response paint(ui, name, rect, tint); resp } - diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a39a68e1..d4021406 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,8 +15,8 @@ pub mod icons; pub mod io; pub mod menus; pub mod palette; -pub mod render_worker; pub mod panels; +pub mod render_worker; pub mod shortcuts; pub mod state; pub mod theme; @@ -119,7 +119,14 @@ impl DrawcraftApp { ui: UiState::default(), views, services, - canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0, worker: None, worker_started: false }, + canvas: CanvasCache { + renderer: drawcraft_render::Renderer::new(), + texture: None, + key: None, + last_ms: 0.0, + worker: None, + worker_started: false, + }, perf: Perf::default(), integrated_titlebar: false, control_rx: None, @@ -198,6 +205,7 @@ impl DrawcraftApp { { *slot = id.to_string(); } + toolbar::remember(self, id); self.ui.flyout = None; } @@ -268,7 +276,8 @@ impl DrawcraftApp { } fn drain_inbox(&mut self) { - let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + let arrived: Vec<(String, Vec)> = + self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); for (name, bytes) in arrived { if let Err(e) = io::open_bytes(self, &name, &bytes, None) { self.status(format!("Couldn't open {name}: {e}")); @@ -337,7 +346,11 @@ impl DrawcraftApp { /// Inject synthetic events (one press/release step per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if !self.synthetic.is_empty() { - let n = self.synthetic.iter().position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1); + let n = self + .synthetic + .iter() + .position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })) + .map_or(self.synthetic.len(), |i| i + 1); raw.events.extend(self.synthetic.drain(..n)); } } @@ -379,4 +392,3 @@ impl DrawcraftApp { let _ = json!(null); } } - diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6a6cf601..7b22b6fc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -79,6 +79,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.control", "Control", "", "{}"), ("window.toolbar", "Tools", "", "{}"), ("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"), + ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ("window.taskBar", "Contextual Task Bar", "", "{}"), ("window.dock", "Panels", "Tab", "{} show/hide all panels"), ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), @@ -100,7 +102,10 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); + app.ui.dialog = Some(crate::state::Dialog::new( + "newDocument", + json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}), + )); Ok(Value::Null) } "file.open" => match s("path") { @@ -135,14 +140,19 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})), - "file.export.png" | "file.exportForScreens" => io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})), + "file.export.png" | "file.exportForScreens" => { + io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})) + } "file.documentSetup" => { let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points"); app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units}))); Ok(Value::Null) } "edit.preferences" => { - app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}))); + app.ui.dialog = Some(crate::state::Dialog::new( + "preferences", + json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}), + )); Ok(Value::Null) } "view.outline" => flag(&mut app.ui.view.outline), @@ -169,7 +179,8 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option { let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0; - let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let center = + p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); match app.view_mut() { Some(v) => { v.zoom = z.clamp(0.0313, 640.0); @@ -206,6 +217,8 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), "window.toolbar" => flag(&mut app.ui.toolbar), "window.toolbarColumns" => flag(&mut app.ui.toolbar_double), + "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), + "window.taskBar" => flag(&mut app.ui.task_bar), "window.dock" => { let on = !(app.ui.dock && app.ui.toolbar); app.ui.dock = on; @@ -294,6 +307,8 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option { "view.rulers" => v.rulers, "window.control" => app.ui.control_bar, "window.toolbar" => app.ui.toolbar, + "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.taskBar" => app.ui.task_bar, "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); match panel { @@ -339,7 +354,19 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool { return (c.enabled)(&app.session).is_ok(); } match id { - "file.save" | "file.saveAs" | "file.saveCopy" | "file.place" | "file.export.svg" | "file.export.png" | "file.exportForScreens" | "file.documentSetup" | "view.zoomIn" | "view.zoomOut" | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), + "file.save" + | "file.saveAs" + | "file.saveCopy" + | "file.place" + | "file.export.svg" + | "file.export.png" + | "file.exportForScreens" + | "file.documentSetup" + | "view.zoomIn" + | "view.zoomOut" + | "view.fitArtboard" + | "view.fitAll" + | "view.actualSize" => app.session.active().is_some(), "file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()), _ => true, } @@ -350,7 +377,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { vec![ ( "DrawCraft", - vec![c("About DrawCraft", "help.about"), Sep, c("Settings…", "edit.preferences"), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, c("Quit DrawCraft", "app.quit")], + vec![ + c("About DrawCraft", "help.about"), + Sep, + c("Settings…", "edit.preferences"), + Sep, + sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), + Sep, + c("Quit DrawCraft", "app.quit"), + ], ), ( "File", @@ -370,7 +405,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Place…", "file.place"), Sep, - sub("Export", vec![c("Export for Screens…", "file.exportForScreens"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S")]), + sub( + "Export", + vec![ + c("Export for Screens…", "file.exportForScreens"), + c("Export As SVG…", "file.export.svg"), + c("Export As PNG…", "file.export.png"), + todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"), + ], + ), todo("Export Selection…"), Sep, todos("Package…", "Cmd+Alt+Shift+P"), @@ -517,8 +560,33 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("Shape", vec![todo("Convert to Shape"), c("Expand Shape", "object.expandShape")]), sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]), sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]), - sub("Blend", vec![todos("Make", "Cmd+Alt+B"), todos("Release", "Cmd+Alt+Shift+B"), Sep, todo("Blend Options…"), Sep, todo("Expand"), Sep, todo("Replace Spine"), todo("Reverse Spine"), todo("Reverse Front to Back")]), - sub("Envelope Distort", vec![todos("Make with Warp…", "Cmd+Alt+Shift+W"), todos("Make with Mesh…", "Cmd+Alt+M"), todos("Make with Top Object", "Cmd+Alt+C"), Sep, todo("Release"), todo("Envelope Options…"), todo("Expand")]), + sub( + "Blend", + vec![ + todos("Make", "Cmd+Alt+B"), + todos("Release", "Cmd+Alt+Shift+B"), + Sep, + todo("Blend Options…"), + Sep, + todo("Expand"), + Sep, + todo("Replace Spine"), + todo("Reverse Spine"), + todo("Reverse Front to Back"), + ], + ), + sub( + "Envelope Distort", + vec![ + todos("Make with Warp…", "Cmd+Alt+Shift+W"), + todos("Make with Mesh…", "Cmd+Alt+M"), + todos("Make with Top Object", "Cmd+Alt+C"), + Sep, + todo("Release"), + todo("Envelope Options…"), + todo("Expand"), + ], + ), sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]), sub("Live Paint", vec![todos("Make", "Cmd+Alt+X"), todo("Merge"), todo("Release"), Sep, todo("Gap Options…"), Sep, todo("Expand")]), sub("Image Trace", vec![todo("Make"), todo("Make and Expand"), todo("Release"), todo("Expand")]), @@ -526,22 +594,51 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, sub("Clipping Mask", vec![c("Make", "object.clippingMask.make"), c("Release", "object.clippingMask.release"), todo("Edit Contents")]), sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), - sub("Artboards", vec![todo("Convert to Artboards"), todo("Rearrange All Artboards…"), Sep, c("Fit to Artwork Bounds", "artboard.fitToArt"), c("Fit to Selected Art", "artboard.fitToSelection")]), + sub( + "Artboards", + vec![ + todo("Convert to Artboards"), + todo("Rearrange All Artboards…"), + Sep, + c("Fit to Artwork Bounds", "artboard.fitToArt"), + c("Fit to Selected Art", "artboard.fitToSelection"), + ], + ), sub("Graph", vec![todo("Type…"), todo("Data…"), todo("Design…"), todo("Column…"), todo("Marker…")]), ], ), ( "Type", vec![ - sub("Font", drawcraft_text::FontDb::global().families().into_iter().take(0).map(|_| todo("")).chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]).collect()), + sub( + "Font", + drawcraft_text::FontDb::global() + .families() + .into_iter() + .take(0) + .map(|_| todo("")) + .chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]) + .collect(), + ), todo("Recent Fonts"), - sub("Size", [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72].iter().map(|_| todo("pt")).take(0).chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]).collect()), + sub( + "Size", + [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72] + .iter() + .map(|_| todo("pt")) + .take(0) + .chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]) + .collect(), + ), Sep, todo("Glyphs"), Sep, todo("Convert To Area Type"), todo("Area Type Options…"), - sub("Type on a Path", vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")]), + sub( + "Type on a Path", + vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")], + ), sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]), todo("Fit Headline"), Sep, @@ -623,11 +720,40 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("3D and Materials", vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]), sub("Convert to Shape", vec![todo("Rectangle…"), todo("Rounded Rectangle…"), todo("Ellipse…")]), todo("Crop Marks"), - sub("Distort & Transform", vec![todo("Free Distort…"), todo("Pucker & Bloat…"), todo("Roughen…"), todo("Transform…"), todo("Tweak…"), todo("Twist…"), todo("Zig Zag…")]), + sub( + "Distort & Transform", + vec![ + todo("Free Distort…"), + todo("Pucker & Bloat…"), + todo("Roughen…"), + todo("Transform…"), + todo("Tweak…"), + todo("Twist…"), + todo("Zig Zag…"), + ], + ), sub("Path", vec![todo("Offset Path…"), todo("Outline Object"), todo("Outline Stroke")]), - sub("Pathfinder", vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract"), todo("Minus Back"), Sep, todo("Divide"), todo("Trim"), todo("Merge"), todo("Crop"), todo("Outline")]), + sub( + "Pathfinder", + vec![ + todo("Add"), + todo("Intersect"), + todo("Exclude"), + todo("Subtract"), + todo("Minus Back"), + Sep, + todo("Divide"), + todo("Trim"), + todo("Merge"), + todo("Crop"), + todo("Outline"), + ], + ), todo("Rasterize…"), - sub("Stylize", vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")]), + sub( + "Stylize", + vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")], + ), sub("SVG Filters", vec![todo("Apply SVG Filter…"), todo("Import SVG Filter…")]), sub( "Warp", @@ -665,9 +791,24 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Pixel Preview", "view.pixelPreview"), todo("Trim View"), c("Presentation Mode", "view.presentation"), - sub("Screen Mode", vec![cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), cp("Full Screen Mode", "view.screenMode", json!({"mode": 2}))]), + sub( + "Screen Mode", + vec![ + cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), + cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), + cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})), + ], + ), Sep, - sub("Proof Setup", vec![todo("Working CMYK"), todo("Internet Standard RGB (sRGB)"), todo("Color blindness – Protanopia-type"), todo("Color blindness – Deuteranopia-type")]), + sub( + "Proof Setup", + vec![ + todo("Working CMYK"), + todo("Internet Standard RGB (sRGB)"), + todo("Color blindness – Protanopia-type"), + todo("Color blindness – Deuteranopia-type"), + ], + ), todo("Proof Colors"), Sep, c("Zoom In", "view.zoomIn"), @@ -689,7 +830,16 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todos("Hide Gradient Annotator", "Cmd+Alt+G"), todo("Hide Corner Widget"), Sep, - sub("Guides", vec![c("Hide Guides", "view.guides"), todos("Lock Guides", "Cmd+Alt+;"), todos("Make Guides", "Cmd+5"), todos("Release Guides", "Cmd+Alt+5"), todo("Clear Guides")]), + sub( + "Guides", + vec![ + c("Hide Guides", "view.guides"), + todos("Lock Guides", "Cmd+Alt+;"), + todos("Make Guides", "Cmd+5"), + todos("Release Guides", "Cmd+Alt+5"), + todo("Clear Guides"), + ], + ), c("Smart Guides", "view.smartGuides"), sub("Perspective Grid", vec![todos("Show Grid", "Cmd+Shift+I"), todo("Define Grid…")]), c("Show Grid", "view.grid"), @@ -707,11 +857,22 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { vec![ c("New Window", "window.newWindow"), sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), - sub("Workspace", vec![todo("Essentials"), todo("Essentials Classic"), todo("Painting"), todo("Typography"), Sep, c("Reset Essentials", "window.workspace.reset")]), + sub( + "Workspace", + vec![ + todo("Essentials"), + todo("Essentials Classic"), + todo("Painting"), + todo("Typography"), + Sep, + c("Reset Essentials", "window.workspace.reset"), + ], + ), Sep, c("Control", "window.control"), + c("Contextual Task Bar", "window.taskBar"), c("Tools", "window.toolbar"), - sub("Toolbars", vec![c("Single / Double Column", "window.toolbarColumns")]), + sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Single / Double Column", "window.toolbarColumns")]), Sep, todo("Actions"), panel("Align", "align"), @@ -743,7 +904,18 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Symbols", "symbols"), panel("Transform", "transform"), panel("Transparency", "transparency"), - sub("Type", vec![panel("Character", "character"), todo("Character Styles"), todo("Glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), todo("Paragraph Styles"), todo("Tabs")]), + sub( + "Type", + vec![ + panel("Character", "character"), + todo("Character Styles"), + todo("Glyphs"), + todo("OpenType"), + panel("Paragraph", "paragraph"), + todo("Paragraph Styles"), + todo("Tabs"), + ], + ), todo("Variables"), Sep, sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]), @@ -763,7 +935,11 @@ pub fn menu_bar(app: &mut DrawcraftApp, ui: &mut egui::Ui) { let tree = menu_tree(); egui::MenuBar::new().ui(ui, |ui| { for (i, (title, items)) in tree.iter().enumerate() { - let text = if i == 0 { egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) } else { egui::RichText::new(*title).size(13.0).color(t.text) }; + let text = if i == 0 { + egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) + } else { + egui::RichText::new(*title).size(13.0).color(t.text) + }; ui.menu_button(text, |ui| { ui.set_min_width(230.0); render_items(app, ui, items, &mut clicked); @@ -884,8 +1060,22 @@ pub fn menu_entries(app: &DrawcraftApp) -> Vec { fn walk(app: &DrawcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { - Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), command: Some(id.to_string()), params: p.clone(), enabled: enabled(app, id), shortcut: shortcut_of(id).unwrap_or("").to_string() }), - Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), label: l.to_string(), command: None, params: Value::Null, enabled: false, shortcut: sc.to_string() }), + Item::Cmd(l, id, p) => out.push(MenuEntry { + path: path.clone(), + label: dynamic_label(app, id, l), + command: Some(id.to_string()), + params: p.clone(), + enabled: enabled(app, id), + shortcut: shortcut_of(id).unwrap_or("").to_string(), + }), + Item::Todo(l, sc) => out.push(MenuEntry { + path: path.clone(), + label: l.to_string(), + command: None, + params: Value::Null, + enabled: false, + shortcut: sc.to_string(), + }), Item::Sub(l, ch) => { let mut p = path.clone(); p.push(l.to_string()); @@ -912,7 +1102,11 @@ mod tests { for it in items { match it { Item::Cmd(_, id, _) => { - if drawcraft_engine::find_command(id).is_none() && !UI_COMMANDS.iter().any(|c| c.0 == *id) && !id.starts_with("object.path.") && *id != "type.createOutlines" { + if drawcraft_engine::find_command(id).is_none() + && !UI_COMMANDS.iter().any(|c| c.0 == *id) + && !id.starts_with("object.path.") + && *id != "type.createOutlines" + { bad.push(id.to_string()); } } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 528fa0c6..aae6f323 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -23,7 +23,11 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { for tool in drawcraft_tools::catalog::all_tools() { items.push((tool.label.to_string(), format!("tool:{}", tool.id), tool.shortcut.unwrap_or("").to_string())); } - let matches: Vec<&(String, String, String)> = items.iter().filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))).take(14).collect(); + let matches: Vec<&(String, String, String)> = items + .iter() + .filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))) + .take(14) + .collect(); let mut run: Option = None; egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| { @@ -32,7 +36,9 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { r.request_focus(); ui.add_space(6.0); for (i, (label, id, sc)) in matches.iter().enumerate() { - let resp = ui.add(egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0)); + let resp = ui.add( + egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0), + ); if resp.clicked() { run = Some(id.clone()); } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 9ea8bc6b..14e1151b 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -24,6 +24,24 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let current = st.active_layer; let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); let mut actions: Vec<(String, serde_json::Value)> = vec![]; + // Search field ("Search All"). + let search_id = egui::Id::new("layers-search"); + let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut query) + .frame(egui::Frame::NONE) + .hint_text(egui::RichText::new("Search All").italics()) + .desired_width(ui.available_width()), + ); + }); + ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + ui.add_space(6.0); let h = ui.available_height() - 34.0; egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; @@ -39,7 +57,13 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); let n = doc.layers.len(); - ui.painter().text(bar.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, format!("{n} Layer{}", if n == 1 { "" } else { "s" }), egui::FontId::proportional(11.5), t.text_dim); + ui.painter().text( + bar.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("{n} Layer{}", if n == 1 { "" } else { "s" }), + egui::FontId::proportional(11.5), + t.text_dim, + ); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() { if app.session.active().is_some_and(|d| !d.selection.is_empty()) { @@ -75,14 +99,25 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } #[allow(clippy::too_many_arguments)] -fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet, current: Option, expanded: &mut HashSet, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) { +fn row( + ui: &mut Ui, + doc: &drawcraft_doc::Document, + n: &Node, + depth: usize, + sel: &HashSet, + current: Option, + expanded: &mut HashSet, + actions: &mut Vec<(String, serde_json::Value)>, + t: &Tokens, +) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag()); let is_sel = sel.contains(&n.id); - let child_sel = !is_sel && n.children().is_some_and(|_| { - let mut any = false; - n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id)); - any - }); + let child_sel = !is_sel + && n.children().is_some_and(|_| { + let mut any = false; + n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id)); + any + }); let color = { let c = doc.layer_color(n.id); Color32::from_rgb(c[0], c[1], c[2]) @@ -92,12 +127,17 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); } - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + if n.is_layer() && Some(n.id) == current { + // Current-layer marker: small triangle in the top-right corner. + let c = r.right_top(); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], Color32::from_gray(0xcc), Stroke::NONE)); + } // Eye and lock columns. - let eye = egui::Rect::from_min_size(r.min, vec2(24.0, ROW)); - let lock = egui::Rect::from_min_size(r.min + vec2(24.0, 0.0), vec2(22.0, ROW)); - ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.border)); - ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.border)); + let eye = egui::Rect::from_min_size(r.min, vec2(25.0, ROW)); + let lock = egui::Rect::from_min_size(r.min + vec2(25.0, 0.0), vec2(25.0, ROW)); + ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); + ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click()); if n.visible { icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon); @@ -123,7 +163,7 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: // Layer colour bar. let mut x = lock.right() + 2.0; if n.is_layer() { - ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(3.0, ROW - 4.0)), 0.0, color); + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(4.0, ROW)), 0.0, color); } x += 6.0 + depth as f32 * 14.0; // Disclosure. @@ -143,14 +183,14 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: } x += 16.0; // Thumbnail. - let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 3.0), vec2(20.0, 20.0)); + let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(22.0, 22.0)); ui.painter().rect_filled(th, 0.0, Color32::WHITE); - ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); thumb(ui, n, th); x += 26.0; // Name. let name = n.display_name(); - let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) }; + let font = egui::FontId::proportional(13.0); let rename_id = egui::Id::new("layers-rename"); let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0)); @@ -176,18 +216,22 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: } } // Target circle and selection square. - let tc = egui::pos2(r.right() - 30.0, r.center().y); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + let tc = egui::pos2(r.right() - 28.0, r.center().y); let styled = !n.appearance.is_basic() || n.opacity < 1.0; - ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.text_dim)); + ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon)); if is_sel { - ui.painter().circle_stroke(tc, 3.0, Stroke::new(1.0, t.text_dim)); + ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon)); } if styled { - ui.painter().circle_filled(tc, 3.5, t.text_dim); + ui.painter().circle_filled(tc, 3.2, t.icon); } - let sq = egui::pos2(r.right() - 12.0, r.center().y); + let sq = egui::pos2(r.right() - 11.0, r.center().y); if is_sel { - ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(8.0, 8.0)), 0.0, color); + let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, color); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); } else if child_sel { ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6fec1836..642b6db4 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -49,7 +49,10 @@ pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) { icons::icon(ui, "library", 40.0, t.text_dim); ui.add_space(8.0); ui.label(egui::RichText::new("Local Libraries").size(14.0).color(t.text)); - dim_label(ui, "Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required."); + dim_label( + ui, + "Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.", + ); }); } @@ -106,8 +109,20 @@ pub fn swatches(app: &mut DrawcraftApp, ui: &mut Ui) { pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) { let current = match first_selected(app) { - Some(n) => if app.session.fill_active { n.appearance.fill_paint() } else { n.appearance.stroke_paint() }, - None => if app.session.fill_active { app.session.paint.fill.clone() } else { app.session.paint.stroke.clone() }, + Some(n) => { + if app.session.fill_active { + n.appearance.fill_paint() + } else { + n.appearance.stroke_paint() + } + } + None => { + if app.session.fill_active { + app.session.paint.fill.clone() + } else { + app.session.paint.stroke.clone() + } + } }; let c = current.color().unwrap_or(Color::BLACK); let mut rgb = c.to_rgba8(1.0); @@ -140,7 +155,8 @@ pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) { let h = i as f32 / n as f32 * 360.0; let v = 1.0 - j as f32 / 4.0 * 0.8; let [cr, cg, cb, _] = Color::from_hsb(h, 1.0, v).to_rgba8(1.0); - let cell = egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0)); + let cell = + egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0)); ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb(cr, cg, cb)); } } @@ -159,7 +175,8 @@ pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) { } fn color_guide(app: &mut DrawcraftApp, ui: &mut Ui) { - let base = first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1)); + let base = + first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1)); for h in drawcraft_color::harmony::Harmony::ALL { ui.horizontal(|ui| { ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(h.label()).size(11.0))); @@ -189,7 +206,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) { let align = st.as_ref().map(|s| s.align).unwrap_or_default(); ui.horizontal(|ui| { dim_label(ui, "Cap:"); - for (v, lbl, name) in [(drawcraft_doc::LineCap::Butt, "Butt", "butt"), (drawcraft_doc::LineCap::Round, "Round", "round"), (drawcraft_doc::LineCap::Square, "Projecting", "square")] { + for (v, lbl, name) in [ + (drawcraft_doc::LineCap::Butt, "Butt", "butt"), + (drawcraft_doc::LineCap::Round, "Round", "round"), + (drawcraft_doc::LineCap::Square, "Projecting", "square"), + ] { if ui.selectable_label(cap == v, lbl).clicked() { app.run("stroke.set", json!({"cap": name})).ok(); } @@ -197,7 +218,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) { }); ui.horizontal(|ui| { dim_label(ui, "Corner:"); - for (v, lbl, name) in [(drawcraft_doc::LineJoin::Miter, "Miter", "miter"), (drawcraft_doc::LineJoin::Round, "Round", "round"), (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel")] { + for (v, lbl, name) in [ + (drawcraft_doc::LineJoin::Miter, "Miter", "miter"), + (drawcraft_doc::LineJoin::Round, "Round", "round"), + (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel"), + ] { if ui.selectable_label(join == v, lbl).clicked() { app.run("stroke.set", json!({"join": name})).ok(); } @@ -205,7 +230,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) { }); ui.horizontal(|ui| { dim_label(ui, "Align:"); - for (v, lbl, name) in [(drawcraft_doc::StrokeAlign::Center, "Center", "center"), (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside")] { + for (v, lbl, name) in [ + (drawcraft_doc::StrokeAlign::Center, "Center", "center"), + (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), + (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside"), + ] { if ui.selectable_label(align == v, lbl).clicked() { app.run("stroke.set", json!({"align": name})).ok(); } @@ -304,7 +333,8 @@ pub fn appearance(app: &mut DrawcraftApp, ui: &mut Ui) { fn graphic_styles(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let styles: Vec<(String, drawcraft_doc::Appearance)> = app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); + let styles: Vec<(String, drawcraft_doc::Appearance)> = + app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); ui.horizontal_wrapped(|ui| { for (name, ap) in styles { let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); @@ -373,7 +403,12 @@ pub fn align(app: &mut DrawcraftApp, ui: &mut Ui) { pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) { dim_label(ui, "Shape Modes:"); ui.horizontal(|ui| { - for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] { + for (icon, tip, op) in [ + ("squares-unite", "Unite", "unite"), + ("squares-subtract", "Minus Front", "minusFront"), + ("squares-intersect", "Intersect", "intersect"), + ("squares-exclude", "Exclude", "exclude"), + ] { if widgets::icon_button(ui, icon, tip, false, 30.0).clicked() { app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); } @@ -381,7 +416,9 @@ pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) { }); dim_label(ui, "Pathfinders:"); ui.horizontal_wrapped(|ui| { - for (tip, op) in [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] { + for (tip, op) in + [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] + { if widgets::flat_button(ui, tip, 72.0).clicked() { app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); } @@ -444,13 +481,20 @@ fn info(app: &mut DrawcraftApp, ui: &mut Ui) { fn artboards(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let abs: Vec<(usize, String)> = app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default(); + let abs: Vec<(usize, String)> = + app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default(); for (i, name) in abs { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{}", i + 1), egui::FontId::proportional(12.0), t.text_dim); + ui.painter().text( + r.left_center() + vec2(6.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("{}", i + 1), + egui::FontId::proportional(12.0), + t.text_dim, + ); ui.painter().text(r.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); } ui.horizontal(|ui| { @@ -464,7 +508,11 @@ fn gradient(app: &mut DrawcraftApp, ui: &mut Ui) { let n = first_selected(app); let paint = n.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or(Paint::None); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); - let preview = if matches!(paint, Paint::Gradient(_)) { paint.clone() } else { Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) }; + let preview = if matches!(paint, Paint::Gradient(_)) { + paint.clone() + } else { + Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) + }; paint_chip(ui, r, &preview); ui.horizontal(|ui| { dim_label(ui, "Type:"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 259bda99..24f576bf 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -59,7 +59,12 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { divider(ui); section_header(ui, "Pathfinder"); ui.horizontal(|ui| { - for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] { + for (icon, tip, op) in [ + ("squares-unite", "Unite", "unite"), + ("squares-subtract", "Minus Front", "minusFront"), + ("squares-intersect", "Intersect", "intersect"), + ("squares-exclude", "Exclude", "exclude"), + ] { if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 410abd31..0b773c16 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -12,7 +12,8 @@ pub fn parse(s: &str) -> Option { if s.is_empty() { return None; } - let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').unwrap_or(("", s)) }; + let (mods_part, key_part) = + if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').unwrap_or(("", s)) }; let mut m = Modifiers::NONE; for part in mods_part.split('+').filter(|p| !p.is_empty()) { match part { @@ -55,7 +56,9 @@ fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> { } } // Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z. - v.sort_by_key(|(sc, _)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)); + v.sort_by_key(|(sc, _)| { + std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8) + }); v } @@ -88,17 +91,24 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { } // Type tool editing: text and editing keys go to the tool. if app.session.tool_wants_text() { - let texts: Vec = ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect()); + let texts: Vec = + ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect()); for t in texts { let _ = app.session.tool_text(&t, view); } - for (k, tk) in [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] { + for (k, tk) in + [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] + { if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { let _ = app.session.tool_key(tk, Mods::default(), view); } } // Enter was already delivered above as ToolKey::Enter (newline). - let fire = all_shortcuts().into_iter().filter(|(sc, _)| sc.modifiers.command).find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc))).map(|(_, id)| id); + let fire = all_shortcuts() + .into_iter() + .filter(|(sc, _)| sc.modifiers.command) + .find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc))) + .map(|(_, id)| id); if let Some(id) = fire { crate::menus::invoke(app, id, json!({})); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6522ffd9..62121b70 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -27,12 +27,17 @@ impl Default for View { /// Illustrator's preset zoom stops, in percent. pub const ZOOM_STOPS: [f64; 26] = [ - 3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, 4800.0, 6400.0, 12800.0, 25600.0, 64000.0, + 3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, + 4800.0, 6400.0, 12800.0, 25600.0, 64000.0, ]; pub fn next_zoom(z: f64, up: bool) -> f64 { let pct = z * 100.0; - let stop = if up { ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0) } else { ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13) }; + let stop = if up { + ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0) + } else { + ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13) + }; stop / 100.0 } @@ -104,7 +109,20 @@ pub struct ViewFlags { impl Default for ViewFlags { fn default() -> Self { - Self { rulers: true, grid: false, guides: true, smart_guides: true, bounding_box: true, outline: false, pixel_preview: false, snap_to_grid: false, snap_to_point: true, artboards: true, transparency_grid: false, edges: true } + Self { + rulers: false, + grid: false, + guides: true, + smart_guides: true, + bounding_box: true, + outline: false, + pixel_preview: false, + snap_to_grid: false, + snap_to_point: true, + artboards: true, + transparency_grid: false, + edges: true, + } } } @@ -147,6 +165,14 @@ pub struct UiState { pub control_bar: bool, pub toolbar: bool, pub toolbar_double: bool, + /// Advanced toolbar (every tool group) instead of the categorized Basic toolbar. + #[serde(default)] + pub toolbar_advanced: bool, + #[serde(default = "yes")] + pub task_bar: bool, + /// Last tool shown in each toolbar slot (keyed by the slot's first tool id). + #[serde(default)] + pub slot_tool: std::collections::BTreeMap, pub status_bar: bool, pub dock: bool, pub view: ViewFlags, @@ -168,12 +194,15 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { Self { - brightness: Brightness::Dark, + brightness: Brightness::MediumDark, dock_tab: DockTab::Properties, open_panel: None, - control_bar: true, + control_bar: false, toolbar: true, toolbar_double: false, + toolbar_advanced: false, + task_bar: true, + slot_tool: Default::default(), status_bar: true, dock: true, view: ViewFlags::default(), @@ -190,6 +219,10 @@ impl Default for UiState { } } +fn yes() -> bool { + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9a42e47d..bfef706c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Brightness { - #[default] Dark, + #[default] MediumDark, MediumLight, Light, @@ -65,6 +65,19 @@ pub struct Tokens { pub measure_bg: Color32, pub button: Color32, pub radius: u8, + /// Field values, button labels, link labels. + pub text_strong: Color32, + /// Panel tab strips, doc-tab bar, dock header. + pub tab_strip: Color32, + /// Outline of secondary (outlined) buttons. + pub button_border: Color32, + /// Default Layer 1 selection colour and selected-anchor fill. + pub selection: Color32, + pub anchor_selected: Color32, + /// Active item text in tool flyouts. + pub flyout_active: Color32, + /// Hint-bar / tab-bar heights etc. scale with this base font size. + pub font_size: f32, } impl Tokens { @@ -95,31 +108,46 @@ impl Tokens { guide: hex(0x4affff), measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235), button: hex(0x444444), - radius: 3, + radius: 2, + text_strong: hex(0xf5f5f5), + tab_strip: hex(0x282828), + button_border: hex(0x6a6a6a), + selection: hex(0x4f80ff), + anchor_selected: hex(0x3d82ff), + flyout_active: hex(0x54b2f7), + font_size: 13.0, }; match b { Brightness::Dark => base, + // Measured from Illustrator 2026 (plan/illustrator/10-observed-ui.md §1). Brightness::MediumDark => Tokens { - app_bar: hex(0x444444), + app_bar: hex(0x535353), panel: hex(0x535353), - panel_darker: hex(0x474747), - input: hex(0x3a3a3a), - input_border: hex(0x6a6a6a), + panel_darker: hex(0x424242), + tab_strip: hex(0x424242), + input: hex(0x454545), + input_border: hex(0x5f5f5f), border: hex(0x383838), - divider: hex(0x656565), - text: hex(0xeeeeee), - text_dim: hex(0xc4c4c4), - icon: hex(0xe6e6e6), - hover: hex(0x646464), - tool_active: hex(0x383838), - row_selected: hex(0x6a6d72), - pasteboard: hex(0x494949), - ruler: hex(0x4c4c4c), - ruler_tick: hex(0xb0b0b0), - button: hex(0x656565), + divider: hex(0x4b4b4b), + text: hex(0xd1d1d1), + text_strong: hex(0xffffff), + text_dim: hex(0xb0b0b0), + text_disabled: hex(0x7a7a7a), + icon: hex(0xc2c2c2), + hover: hex(0x606060), + tool_active: hex(0x303030), + row_selected: hex(0x52677c), + pasteboard: hex(0x606060), + ruler: hex(0x333333), + ruler_tick: hex(0x8a8a8a), + button: hex(0x535353), + button_border: hex(0x747474), ..base }, Brightness::MediumLight => Tokens { + tab_strip: hex(0xa6a6a6), + text_strong: hex(0x000000), + button_border: hex(0x7a7a7a), dark: false, app_bar: hex(0xa9a9a9), panel: hex(0xb8b8b8), @@ -142,6 +170,9 @@ impl Tokens { ..base }, Brightness::Light => Tokens { + tab_strip: hex(0xdddddd), + text_strong: hex(0x000000), + button_border: hex(0x9a9a9a), dark: false, app_bar: hex(0xe4e4e4), panel: hex(0xf0f0f0), @@ -167,7 +198,7 @@ impl Tokens { } pub fn get(ctx: &egui::Context) -> Tokens { - ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::Dark)) + ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::MediumDark)) } pub fn cr(&self) -> CornerRadius { CornerRadius::same(self.radius) @@ -228,7 +259,13 @@ pub fn apply(ctx: &egui::Context, b: Brightness) { v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) }; v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) }; let w = &mut v.widgets; - for (wv, fill) in [(&mut w.noninteractive, t.panel), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.tool_active), (&mut w.open, t.hover)] { + for (wv, fill) in [ + (&mut w.noninteractive, t.panel), + (&mut w.inactive, t.button), + (&mut w.hovered, t.hover), + (&mut w.active, t.tool_active), + (&mut w.open, t.hover), + ] { wv.bg_fill = fill; wv.weak_bg_fill = fill; wv.corner_radius = t.cr(); @@ -242,13 +279,13 @@ pub fn apply(ctx: &egui::Context, b: Brightness) { ctx.global_style_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 5.0); s.spacing.button_padding = egui::vec2(6.0, 2.0); - s.spacing.interact_size = egui::vec2(24.0, 22.0); + s.spacing.interact_size = egui::vec2(24.0, 24.0); s.spacing.menu_margin = egui::Margin::same(6); s.spacing.slider_width = 120.0; s.text_styles = [ - (TextStyle::Small, FontId::proportional(10.5)), - (TextStyle::Body, FontId::proportional(12.5)), - (TextStyle::Button, FontId::proportional(12.5)), + (TextStyle::Small, FontId::proportional(11.0)), + (TextStyle::Body, FontId::proportional(13.0)), + (TextStyle::Button, FontId::proportional(13.0)), (TextStyle::Heading, FontId::new(15.0, FontFamily::Name(FONT_UI_SEMIBOLD.into()))), (TextStyle::Monospace, FontId::new(11.5, FontFamily::Name(FONT_MONO.into()))), ] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 9253c22a..74695fec 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,182 +1,306 @@ -//! The Tools panel: tool groups with flyouts, fill/stroke proxy, colour/gradient/none, drawing -//! modes, screen mode and the Edit Toolbar drawer. +//! The Tools panel. Default: Illustrator 2026's categorized single-column toolbar (Select, Shapes, +//! Draw, Modify, Type, Navigate, Color). Window → Toolbars → Advanced shows every tool group. +//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar. -use drawcraft_tools::{TOOL_GROUPS, ToolInfo}; -use egui::{CornerRadius, Sense, Stroke, Ui, pos2, vec2}; +use drawcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info}; +use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; -use crate::theme::Tokens; -use crate::{DrawcraftApp, icons, menus, widgets}; +use crate::theme::{self, Tokens}; +use crate::{DrawcraftApp, icons, widgets}; -const CELL: f32 = 32.0; +const PITCH: f32 = 30.0; +const WIDTH: f32 = 48.0; -fn tool_tip(t: &ToolInfo) -> String { +/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default). +pub const BASIC: &[(&str, &[&[&str]])] = &[ + ("Select", &[&["selection"], &["directSelection", "groupSelection"], &["lasso", "magicWand"]]), + ( + "Shapes", + &[ + &["rectangle", "roundedRectangle", "star", "lineSegment", "arc", "spiral", "rectangularGrid", "polarGrid", "flare"], + &["ellipse"], + &["polygon"], + &["shaper"], + ], + ), + ( + "Draw", + &[ + &["pencil", "smooth", "pathEraser", "join"], + &["eraser", "scissors", "knife"], + &["paintbrush", "blobBrush"], + &["pen", "addAnchor", "deleteAnchor", "anchorPoint"], + &["curvature"], + ], + ), + ( + "Modify", + &[ + &["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"], + &["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"], + &["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"], + ], + ), + ("Type", &[&["areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"], &["type", "touchType"]]), + ("Navigate", &[&["zoom"], &["hand", "printTiling"], &["rotateView"]]), + ("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]), +]; + +fn tip(t: &ToolInfo) -> String { match t.shortcut { Some(s) => format!("{} ({})", t.label, s), None => t.label.to_string(), } } +/// Slots of the current layout: (category label for the first slot of a category, tool ids). +fn slots(app: &DrawcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> { + if app.ui.toolbar_advanced { + TOOL_GROUPS.iter().map(|g| (None, g.iter().map(|t| t.id).collect())).collect() + } else { + BASIC.iter().flat_map(|(cat, ss)| ss.iter().enumerate().map(move |(i, s)| (if i == 0 { Some(*cat) } else { None }, s.to_vec()))).collect() + } +} + +/// Remember the tool shown in the slot that contains `id`. +pub fn remember(app: &mut DrawcraftApp, id: &str) { + for (_, s) in slots(app) { + if s.contains(&id) { + app.ui.slot_tool.insert(s[0].to_string(), id.to_string()); + } + } +} + pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + let all = slots(app); let avail = ui.available_height(); - let need = TOOL_GROUPS.len() as f32 * (CELL - 1.0) + 190.0; + let labels = all.iter().filter(|s| s.0.is_some()).count() as f32 * 20.0; + let need = all.len() as f32 * PITCH + labels + 190.0; let cols = if app.ui.toolbar_double || avail < need { 2 } else { 1 }; - let w = CELL * cols as f32 + 10.0; - egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(5, 4)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { - // Header: gripper with «/» toggle. - let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::click()); - let chev = if app.ui.toolbar_double { "chevrons-left" } else { "chevrons-right" }; - icons::paint(ui, chev, egui::Rect::from_center_size(hdr.center(), vec2(11.0, 11.0)), if hresp.hovered() { t.text } else { t.text_dim }); - if hresp.on_hover_text("Toggle single/double column").clicked() { - app.ui.toolbar_double = !app.ui.toolbar_double; - } - ui.add_space(2.0); - let active = app.session.tool_id(); - let mut open_flyout: Option<(usize, egui::Rect)> = None; - let groups: Vec<(usize, &'static [ToolInfo])> = TOOL_GROUPS.iter().copied().enumerate().collect(); - for row in groups.chunks(cols) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (gi, group) in row { - let shown_id = app.ui.group_tool.get(*gi).cloned().unwrap_or_else(|| group[0].id.to_string()); - let shown = group.iter().find(|x| x.id == shown_id).unwrap_or(&group[0]); - let is_active = group.iter().any(|x| x.id == active); - let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL - 2.0), Sense::click_and_drag()); - let bg = if is_active { - t.tool_active - } else if resp.hovered() { - t.hover - } else { - egui::Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect.shrink(1.0), CornerRadius::same(4), bg); - let icon = icons::tool_icon(shown.icon); - icons::paint(ui, icon, rect.shrink(7.0), if is_active { t.text } else { t.icon }); - if group.len() > 1 { - let c = rect.right_bottom() + vec2(-4.0, -4.0); - ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-4.0, 0.0), c + vec2(0.0, -4.0)], t.text_dim, Stroke::NONE)); - } - let long_press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); - if resp.secondary_clicked() || long_press || (resp.clicked() && ui.input(|i| i.modifiers.alt) && group.len() > 1) { - if ui.input(|i| i.modifiers.alt) && resp.clicked() { - // Option-click cycles through the group. - let idx = group.iter().position(|x| x.id == shown.id).unwrap_or(0); - let next = group[(idx + 1) % group.len()].id; - app.select_tool(next); - } else if group.len() > 1 { - open_flyout = Some((*gi, rect)); - } - } else if resp.clicked() { - app.select_tool(shown.id); - } - resp.on_hover_text(tool_tip(shown)); + let w = if cols == 2 { 76.0 } else { WIDTH }; + egui::Panel::left("toolbar") + .resizable(false) + .exact_size(w) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + // Dock header with » and a grip strip. + let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click()); + ui.painter().rect_filled(hdr, 0.0, t.tab_strip); + icons::paint( + ui, + if cols == 2 { "chevrons-left" } else { "chevrons-right" }, + egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)), + if hresp.hovered() { t.text_strong } else { t.text }, + ); + if hresp.on_hover_text("Toggle single/double column").clicked() { + app.ui.toolbar_double = !app.ui.toolbar_double; + } + let (grip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::hover()); + for k in 0..6 { + ui.painter().line_segment( + [pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)], + Stroke::new(0.4, t.text_disabled), + ); + } + let active = app.session.tool_id(); + let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None; + let mut i = 0; + while i < all.len() { + if let Some(cat) = all[i].0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + let label = if cols == 1 && cat.len() > 6 { format!("{}...", &cat[..4]) } else { cat.to_string() }; + ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text); } - }); - ui.add_space(1.0); - } - if let Some(f) = open_flyout { - app.ui.flyout = Some(f.0); - ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-anchor"), f.1)); - } - ui.add_space(6.0); - // Fill / stroke proxy. - let (fill, stroke) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone()); - let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) { - Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - _ => (fill, stroke), - }; - ui.vertical_centered(|ui| { - let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 40.0); - if f { - app.session.fill_active = true; + // One row = `cols` slots (a category label always starts a new row). + let mut row = vec![i]; + while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() { + row.push(i + row.len()); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if cols == 1 { + ui.add_space((WIDTH - 36.0) / 2.0); + } else { + ui.add_space(2.0); + } + for &k in &row { + let slot = &all[k].1; + let shown_id = if slot.contains(&active) { + active.to_string() + } else { + app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string()) + }; + let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue }; + let is_active = slot.contains(&active); + let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag()); + let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5)); + if is_active { + ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(well, CornerRadius::same(1), t.hover); + } + let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0)); + icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon }); + if slot.len() > 1 { + let c = rect.center() + vec2(12.5, 10.0); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE)); + } + let long_press = + resp.is_pointer_button_down_on() && ui.input(|inp| inp.pointer.press_start_time().is_some_and(|s| inp.time - s > 0.35)); + let alt = ui.input(|inp| inp.modifiers.alt); + if (resp.secondary_clicked() || long_press) && slot.len() > 1 { + open_flyout = Some((slot.clone(), rect)); + } else if resp.clicked() && alt && slot.len() > 1 { + let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0); + app.select_tool(slot[(idx + 1) % slot.len()]); + } else if resp.clicked() { + app.select_tool(shown.id); + } + resp.on_hover_text(tip(shown)); + } + }); + i += row.len(); } - if s { - app.session.fill_active = false; - } - if swap { - app.run("paint.swap", json!({})).ok(); - } - if def { - app.run("paint.default", json!({})).ok(); + if let Some((slot, rect)) = open_flyout { + app.ui.flyout = Some(0); + ui.data_mut(|d| { + d.insert_temp(egui::Id::new("flyout-anchor"), rect); + d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::>()); + }); } + ui.add_space(8.0); + bottom_controls(app, ui, &t); }); - ui.add_space(4.0); - // Color / Gradient / None. - let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let s = 10.0; - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK); - widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last)); - if resp.on_hover_text("Color (,)").clicked() { - app.run(target, json!({"color": last.to_hex()})).ok(); - } - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default())))); - if resp.on_hover_text("Gradient (.)").clicked() { - app.run(target, json!({"gradient": {"kind": "linear"}})).ok(); - } - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - widgets::paint_chip(ui, r, &drawcraft_color::Paint::None); - if resp.on_hover_text("None (/)").clicked() { - app.run(target, json!({"none": true})).ok(); - } - }); - ui.add_space(6.0); - ui.vertical_centered(|ui| { - let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"]; - let names = ["Draw Normal", "Draw Behind", "Draw Inside"]; - let m = app.ui.draw_mode as usize % 3; - if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() { - app.ui.draw_mode = (app.ui.draw_mode + 1) % 3; - } - if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() { - app.run("view.screenMode", json!({})).ok(); - } - if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() { - app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({}))); - } - }); - }); flyout(app, ui.ctx()); } +fn bottom_controls(app: &mut DrawcraftApp, ui: &mut Ui, t: &Tokens) { + let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) { + Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()), + _ => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), + }; + ui.vertical_centered(|ui| { + let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0); + if f { + app.session.fill_active = true; + } + if s { + app.session.fill_active = false; + } + if swap { + app.run("paint.swap", json!({})).ok(); + } + if def { + app.run("paint.default", json!({})).ok(); + } + }); + ui.add_space(5.0); + let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }; + ui.horizontal(|ui| { + ui.add_space((ui.available_width() - 27.0) / 2.0); + ui.spacing_mut().item_spacing.x = 2.0; + let s = 7.5; + let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK); + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last)); + if resp.on_hover_text("Color (,)").clicked() { + app.run(target, json!({"color": last.to_hex()})).ok(); + } + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default())))); + if resp.on_hover_text("Gradient (.)").clicked() { + app.run(target, json!({"gradient": {"kind": "linear"}})).ok(); + } + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::None); + if resp.on_hover_text("None (/)").clicked() { + app.run(target, json!({"none": true})).ok(); + } + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"]; + let names = ["Draw Normal", "Draw Behind", "Draw Inside"]; + let m = app.ui.draw_mode as usize % 3; + if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() { + app.ui.draw_mode = (app.ui.draw_mode + 1) % 3; + } + if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() { + app.run("view.screenMode", json!({})).ok(); + } + if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() { + app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({}))); + } + }); + let _ = t; +} + fn flyout(app: &mut DrawcraftApp, ctx: &egui::Context) { - let Some(gi) = app.ui.flyout else { return }; - let Some(group) = TOOL_GROUPS.get(gi) else { return }; - let anchor: egui::Rect = ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); + if app.ui.flyout.is_none() { + return; + } + let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap_or_default(); + if tools.is_empty() { + app.ui.flyout = None; + return; + } + let anchor: egui::Rect = + ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); let t = Tokens::get(ctx); let mut chosen = None; - let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(4.0, 0.0)).show(ctx, |ui| { - egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(4)).show(ui, |ui| { - ui.set_min_width(230.0); - for tool in group.iter() { - let active = app.session.tool_id() == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(230.0, 28.0), Sense::click()); - if resp.hovered() || active { - ui.painter().rect_filled(r, CornerRadius::same(4), if resp.hovered() { t.accent_strong } else { t.hover }); - } - if active { - ui.painter().rect_filled(egui::Rect::from_min_size(r.min + vec2(3.0, 11.0), vec2(4.0, 6.0)), 0.0, t.text); - } - let icon = icons::tool_icon(tool.icon); - icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(12.0, 5.0), vec2(18.0, 18.0)), t.text); - ui.painter().text(r.left_center() + vec2(38.0, 0.0), egui::Align2::LEFT_CENTER, tool.label, egui::FontId::proportional(12.5), t.text); - if let Some(sc) = tool.shortcut { - ui.painter().text(r.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, sc, egui::FontId::proportional(12.0), t.text_dim); - } - if resp.clicked() { - chosen = Some(tool.id); - } - } - }); - }); + let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( + ctx, + |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.input_border)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) }) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + let w = 250.0; + for id in &tools { + let Some(tool) = tool_info(id) else { continue }; + let active = app.session.tool_id() == tool.id; + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + if active { + ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text); + } + let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0)); + icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon); + let color = if active { t.flyout_active } else { t.text_strong }; + ui.painter().text( + r.left_center() + vec2(52.0, 0.0), + egui::Align2::LEFT_CENTER, + tool.label, + egui::FontId::proportional(13.0), + color, + ); + if let Some(sc) = tool.shortcut { + ui.painter().text( + r.right_center() - vec2(18.0, 0.0), + egui::Align2::RIGHT_CENTER, + format!("({sc})"), + egui::FontId::proportional(13.0), + color, + ); + } + if resp.clicked() { + chosen = Some(tool.id); + } + } + }); + }, + ); if let Some(id) = chosen { app.select_tool(id); } else if resp.response.clicked_elsewhere() { app.ui.flyout = None; } - let _ = menus::pretty_shortcut(""); + let _ = theme::semibold; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index dc5eb6e5..0951ccce 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -21,26 +21,26 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32 ui.painter().rect_filled(rect, CornerRadius::same(3), bg); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon }); - if !tip.is_empty() { - resp.on_hover_text(tip) - } else { - resp - } + if !tip.is_empty() { resp.on_hover_text(tip) } else { resp } } /// Small flat text button (Quick Actions style). pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); - let bg = if resp.is_pointer_button_down_on() { - t.tool_active + let rect = rect.shrink2(vec2(0.0, 0.5)); + if resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, CornerRadius::same(2), t.tool_active); } else if resp.hovered() { - t.hover - } else { - t.button - }; - ui.painter().rect_filled(rect, CornerRadius::same(4), bg); - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.0), t.text); + ui.painter().rect_filled(rect, CornerRadius::same(2), t.hover); + } + ui.painter().rect_stroke( + rect, + CornerRadius::same(2), + Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border }), + StrokeKind::Inside, + ); + ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.5), t.text_strong); resp } @@ -74,7 +74,7 @@ pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { pub fn section_header(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(2.0); - ui.label(egui::RichText::new(text).font(theme::semibold(12.0)).color(t.text)); + ui.label(egui::RichText::new(text).size(13.0).color(t.text)); ui.add_space(2.0); } @@ -89,7 +89,7 @@ pub fn divider(ui: &mut Ui) { pub fn dim_label(ui: &mut Ui, text: &str) -> Response { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5)) + ui.label(egui::RichText::new(text).color(t.text).size(12.5)) } /// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) @@ -106,9 +106,18 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: let resp = egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(3)) - .inner_margin(egui::Margin::symmetric(5, 2)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text))) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 14.0) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) .inner; let commit = resp.lost_focus() && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); @@ -129,9 +138,18 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu let resp = egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(3)) - .inner_margin(egui::Margin::symmetric(5, 2)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text))) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 14.0) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) .inner; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if resp.lost_focus() && buf != shown { @@ -235,19 +253,15 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { let t = Tokens::get(ui.ctx()); let mut chosen = None; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(CornerRadius::same(3)) - .show(ui, |ui| { - egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { - for (i, o) in options.iter().enumerate() { - if ui.selectable_label(*o == current, *o).clicked() { - chosen = Some(i); - } + let resp = egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(CornerRadius::same(3)).show(ui, |ui| { + egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { + for (i, o) in options.iter().enumerate() { + if ui.selectable_label(*o == current, *o).clicked() { + chosen = Some(i); } - }) - }); + } + }) + }); let _ = resp; chosen } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index af25b11c..c1a78a25 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -63,14 +63,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "ef fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); - INTRA_LAYER_ORDER.iter().any(|chain| { - match ( - chain.iter().position(|n| *n == from), - chain.iter().position(|n| *n == to), - ) { - (Some(f), Some(t)) => t < f, - _ => false, - } + INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) { + (Some(f), Some(t)) => t < f, + _ => false, }) } @@ -120,64 +115,30 @@ pub struct Crate { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Violation { - Unregistered { - krate: String, - }, - Upward { - krate: String, - dep: String, - from: u8, - to: u8, - kind: DepKind, - }, - StandaloneHasWorkspaceDep { - krate: String, - dep: String, - }, - TestkitAsNormalDep { - krate: String, - }, - UiBelowL6 { - krate: String, - dep: String, - layer: u8, - }, + Unregistered { krate: String }, + Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind }, + StandaloneHasWorkspaceDep { krate: String, dep: String }, + TestkitAsNormalDep { krate: String }, + UiBelowL6 { krate: String, dep: String, layer: u8 }, } impl std::fmt::Display for Violation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Violation::Unregistered { krate } => write!( - f, - "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)" - ), - Violation::Upward { - krate, - dep, - from, - to, - kind, - } => write!( - f, - "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers" - ), + Violation::Unregistered { krate } => { + write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)") + } + Violation::Upward { krate, dep, from, to, kind } => { + write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers") + } Violation::StandaloneHasWorkspaceDep { krate, dep } => { - write!( - f, - "{krate}: standalone crate must not depend on workspace crate {dep}" - ) + write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}") } Violation::TestkitAsNormalDep { krate } => { - write!( - f, - "{krate}: drawcraft-testkit may only be a dev-dependency" - ) + write!(f, "{krate}: drawcraft-testkit may only be a dev-dependency") } Violation::UiBelowL6 { krate, dep, layer } => { - write!( - f, - "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+" - ) + write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+") } } } @@ -188,9 +149,7 @@ pub fn check(crates: &[Crate]) -> Vec { let mut out = Vec::new(); for c in crates { let Some(class) = classify(&c.name) else { - out.push(Violation::Unregistered { - krate: c.name.clone(), - }); + out.push(Violation::Unregistered { krate: c.name.clone() }); continue; }; if class == Class::Exempt { @@ -204,10 +163,7 @@ pub fn check(crates: &[Crate]) -> Vec { } if d.workspace { if class == Class::Standalone { - out.push(Violation::StandaloneHasWorkspaceDep { - krate: c.name.clone(), - dep: d.name.clone(), - }); + out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); continue; } match classify(&d.name) { @@ -215,9 +171,7 @@ pub fn check(crates: &[Crate]) -> Vec { None => {} Some(Class::Testkit) => { if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { - krate: c.name.clone(), - }); + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } } Some(dc) => { @@ -234,11 +188,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) { - out.push(Violation::UiBelowL6 { - krate: c.name.clone(), - dep: d.name.clone(), - layer, - }); + out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer }); } } } @@ -249,9 +199,7 @@ pub fn check(crates: &[Crate]) -> Vec { /// Build the model from `cargo metadata --format-version 1 --no-deps`. pub fn from_metadata(meta: &Value) -> Result, String> { - let pkgs = meta["packages"] - .as_array() - .ok_or("metadata: no packages array")?; + let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?; let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect(); let mut out = Vec::new(); for p in pkgs { @@ -265,11 +213,7 @@ pub fn from_metadata(meta: &Value) -> Result, String> { _ => DepKind::Normal, }; let workspace = members.contains(&dname.as_str()) || d["path"].is_string(); - deps.push(Dep { - name: dname, - kind, - workspace, - }); + deps.push(Dep { name: dname, kind, workspace }); } out.push(Crate { name, deps }); } @@ -292,17 +236,7 @@ mod tests { use super::*; fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate { - Crate { - name: name.into(), - deps: deps - .iter() - .map(|(n, k, w)| Dep { - name: (*n).into(), - kind: *k, - workspace: *w, - }) - .collect(), - } + Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() } } use DepKind::*; @@ -311,17 +245,8 @@ mod tests { let g = [ c("drawcraft-geom", &[("kurbo", Normal, false)]), c("drawcraft-doc", &[("drawcraft-geom", Normal, true)]), - c( - "drawcraft-engine", - &[ - ("drawcraft-doc", Normal, true), - ("drawcraft-testkit", Dev, true), - ], - ), - c( - "drawcraft-ui-egui", - &[("drawcraft-engine", Normal, true), ("egui", Normal, false)], - ), + c("drawcraft-engine", &[("drawcraft-doc", Normal, true), ("drawcraft-testkit", Dev, true)]), + c("drawcraft-ui-egui", &[("drawcraft-engine", Normal, true), ("egui", Normal, false)]), c("drawcraft-cli", &[("drawcraft-ui-egui", Normal, true)]), ]; assert!(check(&g).is_empty(), "{:?}", check(&g)); @@ -335,30 +260,20 @@ mod tests { #[test] fn sideways_dependency_flagged() { - let v = check(&[c("drawcraft-ops", &[("drawcraft-algo", Normal, true)])]); + let v = check(&[c("drawcraft-text", &[("drawcraft-pathops", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }])); } #[test] fn l0_foundation_chain_allowed_one_way() { - assert!( - check(&[c( - "drawcraft-raster", - &[ - ("drawcraft-color", Normal, true), - ("drawcraft-geom", Normal, true) - ] - )]) - .is_empty() - ); assert!(check(&[c("drawcraft-color", &[("drawcraft-geom", Normal, true)])]).is_empty()); - let v = check(&[c("drawcraft-geom", &[("drawcraft-raster", Normal, true)])]); + let v = check(&[c("drawcraft-geom", &[("drawcraft-color", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); } #[test] fn self_dev_dependency_ignored() { - assert!(check(&[c("drawcraft-psd", &[("drawcraft-psd", Dev, true)])]).is_empty()); + assert!(check(&[c("drawcraft-doc", &[("drawcraft-doc", Dev, true)])]).is_empty()); } #[test] @@ -367,103 +282,37 @@ mod tests { assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }])); } - #[test] - fn standalone_crates_have_no_workspace_deps() { - for s in [ - "drawcraft-psd", - "drawcraft-codecs", - "drawcraft-adobe-assets", - ] { - let v = check(&[c(s, &[("drawcraft-geom", Normal, true)])]); - assert!( - matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), - "{s}" - ); - assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty()); - } - } - - #[test] - fn standalone_is_usable_from_higher_layers() { - assert!(check(&[c("drawcraft-io", &[("drawcraft-psd", Normal, true)])]).is_empty()); - let v = check(&[c("drawcraft-geom", &[("drawcraft-codecs", Normal, true)])]); - assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); - } - #[test] fn ui_crates_below_l6_flagged() { - for dep in [ - "egui", - "eframe", - "winit", - "egui_kittest", - "rfd", - "bevy_ecs", - "bevy", - ] { + for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("drawcraft-engine", &[(dep, Normal, false)])]); - assert!( - matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), - "{dep}" - ); + assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } - assert!( - check(&[c( - "drawcraft-engine", - &[("egui_extras_not", Normal, false)] - )]) - .is_empty() - ); - assert!(check(&[c("drawcraft-platform", &[("winit", Normal, false)])]).is_empty()); + assert!(check(&[c("drawcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); + assert!(check(&[c("drawcraft-mcp", &[("winit", Normal, false)])]).is_empty()); } #[test] fn unregistered_crate_is_error() { let v = check(&[c("drawcraft-mystery", &[])]); - assert!( - matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery") - ); + assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery")); assert!(v[0].to_string().contains("register")); } #[test] fn testkit_only_as_dev_dependency() { - let v = check(&[c( - "drawcraft-raster", - &[("drawcraft-testkit", Normal, true)], - )]); + let v = check(&[c("drawcraft-render", &[("drawcraft-testkit", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); - assert!(check(&[c("drawcraft-raster", &[("drawcraft-testkit", Dev, true)])]).is_empty()); + assert!(check(&[c("drawcraft-render", &[("drawcraft-testkit", Dev, true)])]).is_empty()); // testkit itself may use anything up to L5 but not L6 crates. - assert!( - check(&[c( - "drawcraft-testkit", - &[("drawcraft-engine", Normal, true)] - )]) - .is_empty() - ); - assert!( - !check(&[c( - "drawcraft-testkit", - &[("drawcraft-ui-egui", Normal, true)] - )]) - .is_empty() - ); + assert!(check(&[c("drawcraft-testkit", &[("drawcraft-engine", Normal, true)])]).is_empty()); + assert!(!check(&[c("drawcraft-testkit", &[("drawcraft-ui-egui", Normal, true)])]).is_empty()); } #[test] fn apps_and_xtask_exempt() { for app in ["drawcraft", "drawcraft-cli", "drawcraft-web", "xtask"] { - assert!( - check(&[c( - app, - &[ - ("egui", Normal, false), - ("drawcraft-ui-egui", Normal, true) - ] - )]) - .is_empty() - ); + assert!(check(&[c(app, &[("egui", Normal, false), ("drawcraft-ui-egui", Normal, true)])]).is_empty()); } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 502325da..b94a0d15 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -47,10 +47,7 @@ fn main() -> ExitCode { /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("xtask has a parent dir") - .to_path_buf() + Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } pub fn cargo() -> Command { @@ -61,26 +58,14 @@ pub fn cargo() -> Command { fn run(mut cmd: Command, what: &str) -> Result<(), String> { eprintln!("$ {what}"); - let status = cmd - .status() - .map_err(|e| format!("{what}: failed to spawn: {e}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("{what}: exited with {status}")) - } + let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; + if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) } } pub fn metadata() -> Result { - let out = cargo() - .args(["metadata", "--format-version", "1", "--no-deps"]) - .output() - .map_err(|e| format!("cargo metadata: {e}"))?; + let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?; if !out.status.success() { - return Err(format!( - "cargo metadata failed:\n{}", - String::from_utf8_lossy(&out.stderr) - )); + return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr))); } serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}")) } @@ -103,12 +88,7 @@ fn cmd_layers() -> Result<(), String> { format!("{}{k}", layers::short_name(&d.name)) }) .collect(); - println!( - "{:<28} {:<14} {}", - c.name, - layers::describe(layers::classify(&c.name)), - ws.join(", ") - ); + println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", ")); } let violations = layers::check(&crates); println!(); @@ -145,11 +125,7 @@ fn cmd_wasm() -> Result<(), String> { for pkg in &set { let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run( - c, - &format!("cargo check --target wasm32-unknown-unknown -p {pkg}"), - ) - .is_ok(); + let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); results.push((pkg.clone(), ok)); } println!("\nwasm32-unknown-unknown check:"); @@ -157,11 +133,7 @@ fn cmd_wasm() -> Result<(), String> { println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); } let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { - Ok(()) - } else { - Err(format!("{failed} crate(s) failed the wasm check")) - } + if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } fn cmd_ci() -> Result<(), String> { @@ -179,14 +151,7 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args([ - "clippy", - "--workspace", - "--all-targets", - "--", - "-D", - "warnings", - ]); + c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy --workspace --all-targets -- -D warnings") }), ), @@ -217,11 +182,7 @@ fn cmd_ci() -> Result<(), String> { } done.push(*name); } - println!( - "\nCI summary: all {} steps passed ({})", - done.len(), - done.join(", ") - ); + println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); Ok(()) } @@ -256,17 +217,7 @@ Tests that use a corpus skip cleanly when it is absent. tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest); run(tar, "tar -xzf PngSuite-2017jul19.tgz")?; let _ = std::fs::remove_file(&tgz); - let n = std::fs::read_dir(&dest) - .map(|d| { - d.flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "png")) - .count() - }) - .unwrap_or(0); + let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0); println!("PngSuite: {n} PNG files in {}", dest.display()); - if n == 0 { - Err("no PNG files extracted".into()) - } else { - Ok(()) - } + if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) } } diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index e9c87b95..f40b1300 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -55,19 +55,11 @@ fn count_tests(src: &str) -> (usize, usize) { } fn exact_tests(pkg: &str) -> Option { - let out = crate::cargo() - .args(["test", "-q", "-p", pkg, "--", "--list"]) - .output() - .ok()?; + let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?; if !out.status.success() { return None; } - Some( - String::from_utf8_lossy(&out.stdout) - .lines() - .filter(|l| l.ends_with(": test")) - .count(), - ) + Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count()) } pub fn run(_root: &Path, exact: bool) -> Result<(), String> { @@ -75,10 +67,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { let mut rows = Vec::new(); for p in meta["packages"].as_array().into_iter().flatten() { let name = p["name"].as_str().unwrap_or("?").to_owned(); - let Some(dir) = p["manifest_path"] - .as_str() - .and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) - else { + let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else { continue; }; let mut c = Counts::default(); @@ -88,15 +77,8 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { } rows.sort_by(|a, b| a.0.cmp(&b.0)); - let exact_col = if exact { - format!(" {:>8}", "harness") - } else { - String::new() - }; - println!( - "{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", - "crate", "files", "lines", "#[test]", "proptest!" - ); + let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() }; + println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!"); println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); let mut total = Counts::default(); let mut total_exact = 0; @@ -109,29 +91,17 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { } (true, None) => format!(" {:>8}", "error"), }; - println!( - "{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", - c.files, c.lines, c.tests, c.proptests - ); + println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests); total.files += c.files; total.lines += c.lines; total.tests += c.tests; total.proptests += c.proptests; } println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); - let ex_s = if exact { - format!(" {total_exact:>8}") - } else { - String::new() - }; - println!( - "{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", - "TOTAL", total.files, total.lines, total.tests, total.proptests - ); + let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() }; + println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests); if !exact { - println!( - "\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)" - ); + println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)"); } Ok(()) } @@ -142,8 +112,7 @@ mod tests { #[test] fn counts_attributes_and_proptest_blocks() { - let src = - "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; + let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; assert_eq!(count_tests(src), (2, 1)); } }