M2/M8/M9/M4.4: drawing + transform tools, live effects, PDF export/import, file/batch commands; UI restyled to measured Illustrator metrics (Medium Dark, categorized toolbar, hint bar, task bar, Layers)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d0007a604d
commit
6d21d36d16
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
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!(
|
||||
"<p>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"
|
||||
)),
|
||||
Err(e) => el.set_inner_html(&format!("<p>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<EffectInfo> {
|
||||
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<EffectInfo> {
|
||||
effect_catalog().into_iter().find(|e| e.id == id)
|
||||
}
|
||||
|
||||
/// Dialog defaults for `id` (`None` for unknown effects).
|
||||
pub fn default_params(id: &str) -> Option<Value> {
|
||||
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> {
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>().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<RasterFx> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Anchor> = 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<Vec<Point>> {
|
||||
let mut polys: Vec<Vec<Point>> = 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<Vec<Point>> = 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<Point> = vec![];
|
||||
let mut y = y0 + step / 2.0;
|
||||
let mut row = 0u64;
|
||||
while y < y1 {
|
||||
let mut xs: Vec<f64> = 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)
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<Anchor> = Vec::new();
|
||||
let mut pending_in: Option<Point> = 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) }
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<CommandSpec> {
|
||||
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<Vec<NodeId>> {
|
||||
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::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
let catalog: Vec<Value> = 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<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId>) {
|
||||
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::<Vec<_>>() {
|
||||
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<Value> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Document> {
|
||||
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"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
|
||||
let mut d = drawcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
return Ok(d);
|
||||
}
|
||||
Err(EngineError::Other(format!("unsupported file `{name}`")))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Vec<u8>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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 }))
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
let corners: Vec<Point> = 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<f64> = 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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- 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<Vec<NodeId>> {
|
||||
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<NodeId> = 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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- artboards ----------
|
||||
|
||||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId>) {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<UiRequest> {
|
||||
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<UiRequest> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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("<svg"));
|
||||
s.execute("document.open", &json!({"name": "copy.drawcraft", "dataBase64": b64})).unwrap();
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
|
||||
let png = s.execute("document.serialize", &json!({"format": "png", "scale": 0.5})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
assert!(png.len() > 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("<svg"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
//! Transform & utility tools driven through `Session::pointer`, and their commands.
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_geom::{Point, Rect};
|
||||
use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> 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<UiRequest> {
|
||||
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<Point> = 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<Point> = 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());
|
||||
}
|
||||
|
|
@ -49,7 +49,13 @@ struct File {
|
|||
/// Serialize a document (pretty = human-diffable).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
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() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>, 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<ExportReport, PdfError> {
|
||||
if doc.artboards.is_empty() {
|
||||
return Err(PdfError::NoArtboards);
|
||||
}
|
||||
let indices: Vec<usize> = 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<i64> {
|
||||
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<i64> {
|
||||
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<String>,
|
||||
images: HashMap<String, Option<Image>>,
|
||||
}
|
||||
|
||||
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<Path> {
|
||||
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<String>) {
|
||||
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<krilla::paint::Paint> {
|
||||
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<Stop> = 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<f32> = 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<Image> {
|
||||
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<BezPath> {
|
||||
let segs: Vec<kurbo::PathSeg> = 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
|
||||
}
|
||||
|
|
@ -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<Document, PdfError> {
|
||||
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<ImportReport, PdfError> {
|
||||
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<Mutex<Vec<String>>> = 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<u8>, u32, u32, (f32, f32));
|
||||
|
||||
enum FrameKind {
|
||||
Root,
|
||||
/// A clip group whose clip path node is stored here.
|
||||
Clip(Box<Node>),
|
||||
/// 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<Arc<Node>>,
|
||||
}
|
||||
|
||||
/// Glyphs drawn consecutively with the same paint, merged into one path.
|
||||
struct GlyphRun {
|
||||
path: BezPath,
|
||||
paint: Paint,
|
||||
opacity: f32,
|
||||
stroke: Option<StrokeProps>,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
next: u64,
|
||||
stack: Vec<Frame>,
|
||||
blend: BlendMode,
|
||||
page: Rect,
|
||||
glyphs: Option<GlyphRun>,
|
||||
images: HashMap<String, ImageBlob>,
|
||||
image_keys: HashMap<u128, (String, u32, u32)>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
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<Arc<Node>> {
|
||||
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("<Text Outlines>".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<GradientPaint> {
|
||||
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::<Vec<_>>())
|
||||
} 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<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<SoftMask<'a>>) {
|
||||
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("<Clipping Path>".into());
|
||||
FrameKind::Clip(Box::new(clip))
|
||||
};
|
||||
self.stack.push(Frame { kind, children: vec![] });
|
||||
}
|
||||
|
||||
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, 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::<Name<'_>>(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<Decoded> = 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<u8> = 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<Decoded> = 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<usize>>,
|
||||
pub compatibility: Compatibility,
|
||||
/// Compress content streams (Flate).
|
||||
pub compress: bool,
|
||||
/// Document title for the metadata; `None` = the document's title.
|
||||
pub title: Option<String>,
|
||||
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
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<usize>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Result of an export with non-fatal warnings (features approximated or dropped).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportReport {
|
||||
pub bytes: Vec<u8>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
|
|
|||
|
|
@ -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<String> = 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::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<u8> {
|
||||
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"));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<BezPath> {
|
||||
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<Rect> {
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = geom_ctx(n);
|
||||
let g = effected_path(n, &bp);
|
||||
let mut r: Option<Rect> = (!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<Rect> {
|
||||
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<u32> = (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Rendered> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Box<dyn Tool>> {
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
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<NodeId> {
|
||||
let mut v: Vec<NodeId> = 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NodeId>,
|
||||
/// Point being dragged: (index, began).
|
||||
drag: Option<(usize, Point, bool)>,
|
||||
/// Last touched point (Backspace removes it).
|
||||
current: Option<usize>,
|
||||
hover: Option<Point>,
|
||||
}
|
||||
|
||||
impl CurvatureTool {
|
||||
/// The path being drawn, if it is still the single selected object and matches our points.
|
||||
fn active(&self, cx: &ToolContext) -> Option<NodeId> {
|
||||
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<Value> = 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<usize> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Point>,
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Point>,
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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<Vec<Action>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<dyn Tool> {
|
|||
"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")))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Box<dyn Tool>> {
|
||||
None
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
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<Overlay> {
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Drag>,
|
||||
preview: Option<Rect>,
|
||||
}
|
||||
|
||||
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<Rect> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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<Action> {
|
||||
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())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<drawcraft_geom::Rect>, p: Point) -> Option<Color> {
|
||||
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<Action> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Drag>,
|
||||
/// 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<usize> {
|
||||
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<Op> {
|
||||
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<Value> = 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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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<Action> {
|
||||
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<f64> = 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32>)> {
|
||||
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<Overlay> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
if key == ToolKey::Escape && self.busy() {
|
||||
self.drag = None;
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Action> {
|
||||
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<Overlay> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NodeId>)>,
|
||||
drag: Option<Drag>,
|
||||
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<Point> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Overlay> {
|
||||
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<Action> {
|
||||
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<f64> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<f64>().sqrt();
|
||||
d <= tol + 1e-6
|
||||
}
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paintable leaves (paths, compounds, text, images) that are visible and editable.
|
||||
fn paintable(doc: &Document) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
let mut compounds: Vec<NodeId> = 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<Action> {
|
||||
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<NodeId> = 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<NodeId> = 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<Point>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn anchors_json(v: &BTreeSet<AnchorRef>) -> 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<Action> {
|
||||
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<AnchorRef>)> = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
let v: BTreeSet<AnchorRef> =
|
||||
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<AnchorRef> = 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<AnchorRef> = 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<Value> = 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<Action> {
|
||||
if key == ToolKey::Escape {
|
||||
self.active = false;
|
||||
self.points.clear();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Point>, 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<Point>, 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::<f32>();
|
||||
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<String> = 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!({}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect();
|
||||
let crumbs: Vec<String> =
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
|||
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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8>)> = 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<u8>)> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Res
|
|||
};
|
||||
let r = match id {
|
||||
"file.newDialog" => {
|
||||
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<Res
|
|||
}
|
||||
}
|
||||
"file.export.svg" => 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<Res
|
|||
}
|
||||
"view.setZoom" => {
|
||||
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<Res
|
|||
"window.control" => 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<bool> {
|
|||
"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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<MenuEntry> {
|
|||
fn walk(app: &DrawcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
|
|||
let current = st.active_layer;
|
||||
let mut expanded: HashSet<u64> = 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<NodeId>, current: Option<NodeId>, expanded: &mut HashSet<u64>, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) {
|
||||
fn row(
|
||||
ui: &mut Ui,
|
||||
doc: &drawcraft_doc::Document,
|
||||
n: &Node,
|
||||
depth: usize,
|
||||
sel: &HashSet<NodeId>,
|
||||
current: Option<NodeId>,
|
||||
expanded: &mut HashSet<u64>,
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
|||
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<String> = 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<String> =
|
||||
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!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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::<Tokens>(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::Dark))
|
||||
ctx.data(|d| d.get_temp::<Tokens>(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()))),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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::<Vec<String>>());
|
||||
});
|
||||
}
|
||||
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<String> = 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Violation> {
|
|||
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<Violation> {
|
|||
}
|
||||
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<Violation> {
|
|||
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<Violation> {
|
|||
}
|
||||
}
|
||||
} 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<Violation> {
|
|||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, 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<Vec<Crate>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value, String> {
|
||||
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(()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,19 +55,11 @@ fn count_tests(src: &str) -> (usize, usize) {
|
|||
}
|
||||
|
||||
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue