diff --git a/.gitignore b/.gitignore index 19cdc639c..0d78b09de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ *~ plan/ +/target +/dist +/corpus +/log +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..f80403542 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1270 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "drawcraft" +version = "0.1.0" + +[[package]] +name = "drawcraft-cli" +version = "0.1.0" + +[[package]] +name = "drawcraft-color" +version = "0.1.0" +dependencies = [ + "kurbo", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "drawcraft-doc" +version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-geom", + "kurbo", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "drawcraft-effects" +version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "drawcraft-geom", + "drawcraft-pathops", + "kurbo", + "serde_json", +] + +[[package]] +name = "drawcraft-engine" +version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-format", + "drawcraft-geom", + "drawcraft-pathops", + "drawcraft-render", + "drawcraft-svg", + "drawcraft-text", + "drawcraft-tools", + "kurbo", + "log", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "drawcraft-format" +version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "drawcraft-geom" +version = "0.1.0" +dependencies = [ + "kurbo", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "drawcraft-mcp" +version = "0.1.0" + +[[package]] +name = "drawcraft-pathops" +version = "0.1.0" +dependencies = [ + "drawcraft-geom", + "kurbo", + "linesweeper", + "proptest", + "thiserror", +] + +[[package]] +name = "drawcraft-pdf" +version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "drawcraft-geom", + "kurbo", + "thiserror", +] + +[[package]] +name = "drawcraft-render" +version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-geom", + "drawcraft-text", + "image", + "log", + "vello_cpu", +] + +[[package]] +name = "drawcraft-svg" +version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-geom", + "image", + "kurbo", + "svgtypes", + "thiserror", + "usvg", +] + +[[package]] +name = "drawcraft-testkit" +version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "drawcraft-geom", +] + +[[package]] +name = "drawcraft-text" +version = "0.1.0" +dependencies = [ + "drawcraft-doc", + "drawcraft-geom", + "harfrust 0.13.3", + "kurbo", + "log", + "skrifa 0.47.0", +] + +[[package]] +name = "drawcraft-tools" +version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-geom", + "drawcraft-text", + "kurbo", + "serde", + "serde_json", +] + +[[package]] +name = "drawcraft-ui-egui" +version = "0.1.0" + +[[package]] +name = "drawcraft-web" +version = "0.1.0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "serde", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linesweeper" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" +dependencies = [ + "arrayvec", + "kurbo", + "polycool", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "harfrust 0.12.0", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa 0.44.0", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "png", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..f4a8b85ba --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,71 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/drawcraft", "apps/drawcraft-cli", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/drawcraft" + +[workspace.dependencies] +# internal +drawcraft-geom = { path = "crates/geom" } +drawcraft-color = { path = "crates/color" } +drawcraft-doc = { path = "crates/doc" } +drawcraft-pathops = { path = "crates/pathops" } +drawcraft-text = { path = "crates/text" } +drawcraft-effects = { path = "crates/effects" } +drawcraft-render = { path = "crates/render" } +drawcraft-svg = { path = "crates/svg" } +drawcraft-pdf = { path = "crates/pdf" } +drawcraft-format = { path = "crates/format" } +drawcraft-tools = { path = "crates/tools" } +drawcraft-engine = { path = "crates/engine" } +drawcraft-ui-egui = { path = "crates/ui-egui" } +drawcraft-mcp = { path = "crates/mcp" } +drawcraft-testkit = { path = "crates/testkit" } + +# foundation +kurbo = { version = "0.13", features = ["serde"] } +peniko = "0.6" +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +log = "0.4" +proptest = "1" + +# rendering / formats +vello_cpu = "0.2" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +usvg = "0.48" +krilla = "0.8" +skrifa = "0.47" +harfrust = "0.13" +linesweeper = "0.4" + +# ui +egui = "0.36" +egui_extras = { version = "0.36", default-features = false, features = ["svg"] } +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" +rfd = "0.17" + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml new file mode 100644 index 000000000..60dd2088e --- /dev/null +++ b/apps/drawcraft-cli/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "drawcraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/apps/drawcraft-cli/src/main.rs b/apps/drawcraft-cli/src/main.rs new file mode 100644 index 000000000..f328e4d9d --- /dev/null +++ b/apps/drawcraft-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/apps/drawcraft-web/Cargo.toml b/apps/drawcraft-web/Cargo.toml new file mode 100644 index 000000000..c1a77c396 --- /dev/null +++ b/apps/drawcraft-web/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "drawcraft-web" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/apps/drawcraft-web/src/main.rs b/apps/drawcraft-web/src/main.rs new file mode 100644 index 000000000..f328e4d9d --- /dev/null +++ b/apps/drawcraft-web/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/apps/drawcraft/Cargo.toml b/apps/drawcraft/Cargo.toml new file mode 100644 index 000000000..ed96ef42c --- /dev/null +++ b/apps/drawcraft/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "drawcraft" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs new file mode 100644 index 000000000..f328e4d9d --- /dev/null +++ b/apps/drawcraft/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 000000000..458cd0601 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..b7aaca8de Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 000000000..47f8ab1d6 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 000000000..711830ede Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-JetBrainsMono.txt b/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 000000000..5ceee0025 --- /dev/null +++ b/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-SourceSans3.txt b/assets/fonts/OFL-SourceSans3.txt new file mode 100644 index 000000000..22c601b82 --- /dev/null +++ b/assets/fonts/OFL-SourceSans3.txt @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-SourceSerif4.txt b/assets/fonts/OFL-SourceSerif4.txt new file mode 100644 index 000000000..5871e1f3d --- /dev/null +++ b/assets/fonts/OFL-SourceSerif4.txt @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/SourceSans3-Bold.ttf b/assets/fonts/SourceSans3-Bold.ttf new file mode 100644 index 000000000..85693ccc8 Binary files /dev/null and b/assets/fonts/SourceSans3-Bold.ttf differ diff --git a/assets/fonts/SourceSans3-It.ttf b/assets/fonts/SourceSans3-It.ttf new file mode 100644 index 000000000..8e0576f24 Binary files /dev/null and b/assets/fonts/SourceSans3-It.ttf differ diff --git a/assets/fonts/SourceSans3-Regular.ttf b/assets/fonts/SourceSans3-Regular.ttf new file mode 100644 index 000000000..c5dd0b88c Binary files /dev/null and b/assets/fonts/SourceSans3-Regular.ttf differ diff --git a/assets/fonts/SourceSans3-Semibold.ttf b/assets/fonts/SourceSans3-Semibold.ttf new file mode 100644 index 000000000..4aff9a3ae Binary files /dev/null and b/assets/fonts/SourceSans3-Semibold.ttf differ diff --git a/assets/fonts/SourceSerif4-Regular.ttf b/assets/fonts/SourceSerif4-Regular.ttf new file mode 100644 index 000000000..964a2bf89 Binary files /dev/null and b/assets/fonts/SourceSerif4-Regular.ttf differ diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 000000000..07c936948 --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "drawcraft-color" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft color" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs new file mode 100644 index 000000000..a5f61decc --- /dev/null +++ b/crates/color/src/blend.rs @@ -0,0 +1,100 @@ +//! Blend modes (the 16 modes of Illustrator's Transparency panel, same as PDF's). + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BlendMode { + #[default] + Normal, + Darken, + Multiply, + ColorBurn, + Lighten, + Screen, + ColorDodge, + Overlay, + SoftLight, + HardLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// In Transparency-panel order (grouped as Illustrator groups them). + pub const ALL: [BlendMode; 16] = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + ]; + + pub fn label(self) -> &'static str { + match self { + BlendMode::Normal => "Normal", + BlendMode::Darken => "Darken", + BlendMode::Multiply => "Multiply", + BlendMode::ColorBurn => "Color Burn", + BlendMode::Lighten => "Lighten", + BlendMode::Screen => "Screen", + BlendMode::ColorDodge => "Color Dodge", + BlendMode::Overlay => "Overlay", + BlendMode::SoftLight => "Soft Light", + BlendMode::HardLight => "Hard Light", + BlendMode::Difference => "Difference", + BlendMode::Exclusion => "Exclusion", + BlendMode::Hue => "Hue", + BlendMode::Saturation => "Saturation", + BlendMode::Color => "Color", + BlendMode::Luminosity => "Luminosity", + } + } + /// Index of the separator group (the panel draws a divider between groups). + pub fn group(self) -> u8 { + match self { + BlendMode::Normal => 0, + BlendMode::Darken | BlendMode::Multiply | BlendMode::ColorBurn => 1, + BlendMode::Lighten | BlendMode::Screen | BlendMode::ColorDodge => 2, + BlendMode::Overlay | BlendMode::SoftLight | BlendMode::HardLight => 3, + BlendMode::Difference | BlendMode::Exclusion => 4, + _ => 5, + } + } + /// Parse a label or identifier, case/space-insensitively. + pub fn parse(s: &str) -> Option { + let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], ""); + let want = norm(s); + Self::ALL.into_iter().find(|m| norm(m.label()) == want) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_labels() { + assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn)); + assert_eq!(BlendMode::parse("ColorBurn"), Some(BlendMode::ColorBurn)); + assert_eq!(BlendMode::parse("soft_light"), Some(BlendMode::SoftLight)); + assert_eq!(BlendMode::parse("nope"), None); + for m in BlendMode::ALL { + assert_eq!(BlendMode::parse(m.label()), Some(m)); + } + } +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs new file mode 100644 index 000000000..3554ba16d --- /dev/null +++ b/crates/color/src/gradient.rs @@ -0,0 +1,224 @@ +//! Gradients: definitions (swatch-able) and their placement on an object. + +use kurbo::{Affine, Point, Rect, Vec2}; +use serde::{Deserialize, Serialize}; + +use crate::Color; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GradientKind { + #[default] + Linear, + Radial, + /// Freeform gradients (points/lines) — rendered via a mesh approximation. + Freeform, +} + +impl GradientKind { + pub fn label(self) -> &'static str { + match self { + GradientKind::Linear => "Linear", + GradientKind::Radial => "Radial", + GradientKind::Freeform => "Freeform", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientStop { + /// 0..=1 along the gradient. + pub offset: f32, + pub color: Color, + #[serde(default = "one")] + pub opacity: f32, + /// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5. + #[serde(default = "half")] + pub midpoint: f32, +} + +fn one() -> f32 { + 1.0 +} +fn half() -> f32 { + 0.5 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gradient { + pub kind: GradientKind, + pub stops: Vec, +} + +impl Default for Gradient { + /// Illustrator's default "White, Black" gradient. + fn default() -> Self { + Self { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 }, + ], + } + } +} + +impl Gradient { + /// Colour and opacity at `t` (honours midpoints). + pub fn sample(&self, t: f32) -> (Color, f32) { + let stops = &self.stops; + if stops.is_empty() { + return (Color::BLACK, 1.0); + } + if t <= stops[0].offset { + return (stops[0].color, stops[0].opacity); + } + for w in stops.windows(2) { + let (a, b) = (&w[0], &w[1]); + if t <= b.offset { + let span = (b.offset - a.offset).max(1e-6); + let u = (t - a.offset) / span; + // Map through the midpoint: u=mid → 0.5. + let m = a.midpoint.clamp(0.01, 0.99); + let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; + return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v); + } + } + let l = stops.last().unwrap(); + (l.color, l.opacity) + } + /// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints). + pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> { + let mut out = Vec::new(); + for (i, s) in self.stops.iter().enumerate() { + out.push((s.offset, s.color, s.opacity)); + if let Some(n) = self.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let t = s.offset + (n.offset - s.offset) * s.midpoint; + let (c, o) = self.sample(t); + out.push((t, c, o)); + } + } + out + } + pub fn reverse(&mut self) { + self.stops.reverse(); + for s in &mut self.stops { + s.offset = 1.0 - s.offset; + } + } + pub fn sort(&mut self) { + self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + } +} + +/// Where the gradient sits on an object, in document coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientGeom { + pub start: Point, + pub end: Point, + /// Radial aspect ratio (height / width), 1 = circle. + #[serde(default = "one64")] + pub aspect: f64, +} + +fn one64() -> f64 { + 1.0 +} + +impl GradientGeom { + /// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`, + /// radial is centred with radius = half the larger dimension. + pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { + let c = b.center(); + match kind { + GradientKind::Radial => { + let r = b.width().max(b.height()) / 2.0; + Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 } + } + _ => { + let a = angle_deg.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); + // Project the box corners onto the direction to cover the whole box. + let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; + Self { start: c - d * half, end: c + d * half, aspect: 1.0 } + } + } + } + pub fn transform(&mut self, a: Affine) { + self.start = a * self.start; + self.end = a * self.end; + } + pub fn angle_deg(&self) -> f64 { + let v = self.end - self.start; + (-v.y).atan2(v.x).to_degrees() + } + pub fn length(&self) -> f64 { + (self.end - self.start).hypot() + } +} + +/// A gradient applied to a fill or stroke. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientPaint { + pub gradient: Gradient, + /// None = fit to the object's bounds at `angle` each render (fresh gradients behave like this). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub geom: Option, + #[serde(default)] + pub angle: f64, + /// Name of the gradient swatch, if linked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swatch: Option, +} + +impl GradientPaint { + pub fn new(gradient: Gradient) -> Self { + Self { gradient, geom: None, angle: 0.0, swatch: None } + } + pub fn resolve(&self, bounds: Rect) -> GradientGeom { + self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_endpoints_and_mid() { + let g = Gradient::default(); + assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff"); + assert_eq!(g.sample(1.0).0.to_hex(), "#000000"); + let mid = g.sample(0.5).0.to_rgb()[0]; + assert!((mid - 0.5).abs() < 1e-5); + } + + #[test] + fn midpoint_shifts() { + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; + // At t = 0.25 we should be half way. + assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.expanded_stops().len(), 3); + } + + #[test] + fn fit_linear_horizontal() { + let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + assert_eq!(g.start, Point::new(0.0, 25.0)); + assert_eq!(g.end, Point::new(100.0, 25.0)); + assert!((g.angle_deg()).abs() < 1e-9); + let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0); + assert!((v.angle_deg() - 90.0).abs() < 1e-9); + assert!((v.length() - 50.0).abs() < 1e-9); + } + + #[test] + fn reverse_stops() { + let mut g = Gradient::default(); + g.reverse(); + assert_eq!(g.stops[0].color.to_hex(), "#000000"); + assert_eq!(g.stops[0].offset, 0.0); + } +} diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs new file mode 100644 index 000000000..01336d20c --- /dev/null +++ b/crates/color/src/harmony.rs @@ -0,0 +1,83 @@ +//! Colour harmony rules (the Color Guide panel). + +use crate::Color; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Harmony { + Complementary, + SplitComplementary, + LeftComplement, + RightComplement, + Analogous, + Triad, + Tetrad, + Square, + Shades, +} + +impl Harmony { + pub const ALL: [Harmony; 9] = [ + Harmony::Complementary, + Harmony::SplitComplementary, + Harmony::LeftComplement, + Harmony::RightComplement, + Harmony::Analogous, + Harmony::Triad, + Harmony::Tetrad, + Harmony::Square, + Harmony::Shades, + ]; + pub fn label(self) -> &'static str { + match self { + Harmony::Complementary => "Complementary", + Harmony::SplitComplementary => "Split Complementary", + Harmony::LeftComplement => "Left Complement", + Harmony::RightComplement => "Right Complement", + Harmony::Analogous => "Analogous", + Harmony::Triad => "Triad", + Harmony::Tetrad => "Tetrad", + Harmony::Square => "Square", + Harmony::Shades => "Shades", + } + } + /// Colours of the harmony, base colour first. + pub fn apply(self, base: Color) -> Vec { + let [h, s, v] = base.to_hsb(); + let hue = |d: f32| Color::from_hsb(h + d, s, v); + match self { + Harmony::Complementary => vec![base, hue(180.0)], + Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], + Harmony::LeftComplement => vec![base, hue(150.0)], + Harmony::RightComplement => vec![base, hue(210.0)], + Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)], + Harmony::Triad => vec![base, hue(120.0), hue(240.0)], + Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], + Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], + Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(), + } + } +} + +/// Tints and shades row for the Color Guide variation grid. +pub fn variations(base: Color, steps: usize) -> Vec { + let n = steps.max(1) as f32; + (0..steps) + .map(|i| { + let t = (i as f32 + 1.0) / (n + 1.0); + if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn complementary_red() { + let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(c[1].to_hex(), "#00ffff"); + assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); + assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 000000000..2af172934 --- /dev/null +++ b/crates/color/src/lib.rs @@ -0,0 +1,248 @@ +//! Colour, paint and blend-mode types for DrawCraft. +//! +//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so +//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`]. +#![forbid(unsafe_code)] + +pub mod blend; +pub mod gradient; +pub mod harmony; +pub mod swatch; + +pub use blend::BlendMode; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; +pub use swatch::{Swatch, SwatchGroup, default_swatches}; + +use serde::{Deserialize, Serialize}; + +/// A colour in its authoring model. Components are 0..=1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "model", rename_all = "lowercase")] +pub enum Color { + Rgb { r: f32, g: f32, b: f32 }, + Cmyk { c: f32, m: f32, y: f32, k: f32 }, + Gray { k: f32 }, +} + +impl Default for Color { + fn default() -> Self { + Color::BLACK + } +} + +impl Color { + pub const BLACK: Color = Color::Rgb { r: 0.0, g: 0.0, b: 0.0 }; + pub const WHITE: Color = Color::Rgb { r: 1.0, g: 1.0, b: 1.0 }; + + pub fn rgb(r: f32, g: f32, b: f32) -> Self { + Color::Rgb { r, g, b } + } + pub fn rgb8(r: u8, g: u8, b: u8) -> Self { + Color::Rgb { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0 } + } + pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self { + Color::Cmyk { c, m, y, k } + } + pub fn gray(k: f32) -> Self { + Color::Gray { k } + } + + /// Display RGB (naive, profile-free conversion; colour management comes later). + pub fn to_rgb(&self) -> [f32; 3] { + match *self { + Color::Rgb { r, g, b } => [r, g, b], + Color::Cmyk { c, m, y, k } => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + // Illustrator's Gray is ink percentage: 0 = white, 1 = black. + Color::Gray { k } => [1.0 - k; 3], + } + } + pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] { + let [r, g, b] = self.to_rgb(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [q(r), q(g), q(b), q(alpha)] + } + pub fn to_cmyk(&self) -> [f32; 4] { + match *self { + Color::Cmyk { c, m, y, k } => [c, m, y, k], + _ => { + let [r, g, b] = self.to_rgb(); + let k = 1.0 - r.max(g).max(b); + if k >= 1.0 { + return [0.0, 0.0, 0.0, 1.0]; + } + [(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k] + } + } + } + /// HSB with hue in degrees 0..360, saturation and brightness 0..1. + pub fn to_hsb(&self) -> [f32; 3] { + let [r, g, b] = self.to_rgb(); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / d).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + let s = if max <= 0.0 { 0.0 } else { d / max }; + [h, s, max] + } + pub fn from_hsb(h: f32, s: f32, v: f32) -> Self { + let h = h.rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + Color::Rgb { r: r + m, g: g + m, b: b + m } + } + /// `#rrggbb` (display RGB). + pub fn to_hex(&self) -> String { + let [r, g, b, _] = self.to_rgba8(1.0); + format!("#{r:02x}{g:02x}{b:02x}") + } + /// Parse `#rgb`, `#rrggbb` or a few CSS names. + pub fn from_hex(s: &str) -> Option { + let s = s.trim(); + match s.to_ascii_lowercase().as_str() { + "black" => return Some(Color::BLACK), + "white" => return Some(Color::WHITE), + "red" => return Some(Color::rgb(1.0, 0.0, 0.0)), + "green" => return Some(Color::rgb8(0, 128, 0)), + "blue" => return Some(Color::rgb(0.0, 0.0, 1.0)), + _ => {} + } + let h = s.strip_prefix('#').unwrap_or(s); + let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok(); + match h.len() { + 3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)), + 6 | 8 => Some(Color::rgb8(p(0, 2)?, p(2, 2)?, p(4, 2)?)), + _ => None, + } + } + /// Complement (Illustrator's Edit → Edit Colors → Invert is 1 - rgb; this is hue + 180). + pub fn complement(&self) -> Self { + let [h, s, v] = self.to_hsb(); + Color::from_hsb(h + 180.0, s, v) + } + pub fn invert(&self) -> Self { + let [r, g, b] = self.to_rgb(); + Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) + } + /// Linear interpolation in display RGB. + pub fn lerp(&self, other: &Color, t: f32) -> Color { + let a = self.to_rgb(); + let b = other.to_rgb(); + Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) + } + pub fn model_name(&self) -> &'static str { + match self { + Color::Rgb { .. } => "RGB", + Color::Cmyk { .. } => "CMYK", + Color::Gray { .. } => "Grayscale", + } + } +} + +/// What fills or strokes an object. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Paint { + #[default] + None, + Solid { + color: Color, + /// Name of the global swatch this colour is linked to (edits to the swatch update it). + #[serde(default, skip_serializing_if = "Option::is_none")] + swatch: Option, + }, + Gradient(Box), + Pattern { + pattern: String, + }, +} + +impl Paint { + pub fn solid(c: Color) -> Self { + Paint::Solid { color: c, swatch: None } + } + pub fn is_none(&self) -> bool { + matches!(self, Paint::None) + } + pub fn color(&self) -> Option { + match self { + Paint::Solid { color, .. } => Some(*color), + _ => None, + } + } + pub fn label(&self) -> String { + match self { + Paint::None => "None".into(), + Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()), + Paint::Solid { color, .. } => color.to_hex(), + Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()), + Paint::Pattern { pattern } => format!("pattern {pattern}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_roundtrip() { + let c = Color::from_hex("#ff8000").unwrap(); + assert_eq!(c.to_hex(), "#ff8000"); + assert_eq!(Color::from_hex("#fff").unwrap().to_hex(), "#ffffff"); + assert_eq!(Color::from_hex("zz"), None); + } + + #[test] + fn hsb_roundtrip() { + for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] { + let c = Color::from_hex(hex).unwrap(); + let [h, s, v] = c.to_hsb(); + assert_eq!(Color::from_hsb(h, s, v).to_hex(), hex, "{hex}"); + } + } + + #[test] + fn cmyk_conversions() { + assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_hex(), "#000000"); + assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_hex(), "#00ffff"); + let k = Color::rgb(1.0, 0.0, 0.0).to_cmyk(); + assert_eq!(k, [0.0, 1.0, 1.0, 0.0]); + } + + #[test] + fn gray_is_ink() { + assert_eq!(Color::gray(0.0).to_hex(), "#ffffff"); + assert_eq!(Color::gray(1.0).to_hex(), "#000000"); + } + + #[test] + fn complement_and_invert() { + assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement().to_hex(), "#00ffff"); + assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff"); + } + + #[test] + fn paint_serde() { + let p = Paint::solid(Color::rgb8(10, 20, 30)); + let s = serde_json::to_string(&p).unwrap(); + assert!(s.contains("\"type\":\"solid\"")); + assert_eq!(serde_json::from_str::(&s).unwrap(), p); + } +} diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs new file mode 100644 index 000000000..8423371a1 --- /dev/null +++ b/crates/color/src/swatch.rs @@ -0,0 +1,119 @@ +//! Swatches and the default swatch set (our own palette, not Adobe's). + +use serde::{Deserialize, Serialize}; + +use crate::{Color, Gradient, GradientKind, GradientStop, Paint}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Swatch { + pub name: String, + pub paint: Paint, + /// Global swatches update every object that uses them. + #[serde(default)] + pub global: bool, + /// Spot colour (prints on its own plate). + #[serde(default)] + pub spot: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SwatchGroup { + pub name: String, + pub swatches: Vec, +} + +fn solid(name: &str, hex: &str) -> Swatch { + Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false } +} + +/// The default document swatches: None, Registration-like black, white, black, a spectrum, greys, +/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.) +pub fn default_swatches() -> (Vec, Vec) { + let mut s = + vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")]; + let spectrum = [ + ("Red", "#ed1c24"), + ("Orange Red", "#f15a24"), + ("Orange", "#f7931e"), + ("Amber", "#fbb03b"), + ("Yellow", "#fcee21"), + ("Yellow Green", "#d9e021"), + ("Lime", "#8cc63f"), + ("Green", "#39b54a"), + ("Emerald", "#009245"), + ("Teal", "#006837"), + ("Aqua", "#22b573"), + ("Cyan Green", "#00a99d"), + ("Cyan", "#29abe2"), + ("Azure", "#0071bc"), + ("Blue", "#2e3192"), + ("Indigo", "#1b1464"), + ("Violet", "#662d91"), + ("Purple", "#93278f"), + ("Magenta", "#9e005d"), + ("Rose", "#d4145a"), + ("Pink", "#ed1e79"), + ("Brown", "#c7b299"), + ("Tan", "#998675"), + ("Coffee", "#736357"), + ("Chocolate", "#534741"), + ("Sand", "#c69c6d"), + ("Copper", "#a67c52"), + ("Rust", "#8c6239"), + ("Walnut", "#754c24"), + ("Espresso", "#603813"), + ]; + s.extend(spectrum.iter().map(|(n, h)| solid(n, h))); + let grays: Vec = (1..=9) + .rev() + .map(|i| { + let k = i as f32 / 10.0; + let c = Color::gray(k); + Swatch { name: format!("K={}", (k * 100.0) as u32), paint: Paint::solid(c), global: false, spot: false } + }) + .collect(); + let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch { + name: name.into(), + paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient { + kind, + stops: vec![ + GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 }, + ], + }))), + global: false, + spot: false, + }; + s.push(grad("White, Black", "#ffffff", "#000000", GradientKind::Linear)); + s.push(grad("Radial White, Black", "#ffffff", "#000000", GradientKind::Radial)); + s.push(grad("Sunset", "#fbb03b", "#d4145a", GradientKind::Linear)); + s.push(grad("Ocean", "#29abe2", "#2e3192", GradientKind::Linear)); + let groups = vec![ + SwatchGroup { name: "Grays".into(), swatches: grays }, + SwatchGroup { + name: "Brights".into(), + swatches: vec![ + solid("Bright Red", "#ff1d25"), + solid("Bright Yellow", "#ffe600"), + solid("Bright Green", "#00e676"), + solid("Bright Blue", "#2979ff"), + solid("Bright Violet", "#d500f9"), + ], + }, + ]; + (s, groups) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_start_with_none() { + let (s, g) = default_swatches(); + assert!(s[0].paint.is_none()); + assert!(s.len() > 30); + assert_eq!(g.len(), 2); + assert_eq!(g[0].swatches.len(), 9); + } +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 000000000..7a3dcdcd5 --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "drawcraft-doc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft document model: artboards, layers, objects, appearance (pure data + serde)" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +kurbo = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs new file mode 100644 index 000000000..eb7c25a2c --- /dev/null +++ b/crates/doc/src/appearance.rs @@ -0,0 +1,354 @@ +//! The appearance model: a stack of fills and strokes, each with its own opacity, blend mode and +//! effects, plus object-level effects. + +use drawcraft_color::{BlendMode, Color, Paint}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineCap { + #[default] + Butt, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + #[default] + Miter, + Round, + Bevel, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Dash { + /// Dash, gap, dash, gap… (up to 6 values in the Stroke panel). + pub pattern: Vec, + #[serde(default)] + pub offset: f64, + /// "Aligns dashes to corners and path ends, adjusting lengths to fit". + #[serde(default)] + pub align_corners: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Arrowhead { + Triangle, + TriangleOpen, + Circle, + CircleOpen, + Square, + SquareOpen, + Bar, + Diamond, + Arrow, + ArrowOpen, +} + +impl Arrowhead { + pub const ALL: [Arrowhead; 10] = [ + Arrowhead::Arrow, + Arrowhead::ArrowOpen, + Arrowhead::Triangle, + Arrowhead::TriangleOpen, + Arrowhead::Circle, + Arrowhead::CircleOpen, + Arrowhead::Square, + Arrowhead::SquareOpen, + Arrowhead::Diamond, + Arrowhead::Bar, + ]; +} + +/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WidthProfile { + pub points: Vec<(f64, f64, f64)>, +} + +impl WidthProfile { + /// Width factor at `t` (average of both sides), linear between points. + pub fn at(&self, t: f64) -> (f64, f64) { + let p = &self.points; + if p.is_empty() { + return (1.0, 1.0); + } + if t <= p[0].0 { + return (p[0].1, p[0].2); + } + for w in p.windows(2) { + if t <= w[1].0 { + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); + return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); + } + } + let l = p.last().unwrap(); + (l.1, l.2) + } + /// Illustrator's "Width Profile 1" (lens shape) analogue. + pub fn lens() -> Self { + Self { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] } + } + pub fn taper_end() -> Self { + Self { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] } + } + pub fn taper_start() -> Self { + Self { points: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] } + } +} + +/// A live effect in an appearance stack. Parameters are interpreted by `drawcraft-effects`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Effect { + /// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`. + pub id: String, + #[serde(default)] + pub params: serde_json::Value, + #[serde(default = "yes")] + pub visible: bool, +} + +fn yes() -> bool { + true +} +fn one() -> f32 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FillLayer { + pub paint: Paint, + #[serde(default = "one")] + pub opacity: f32, + #[serde(default)] + pub blend: BlendMode, + #[serde(default = "yes")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, +} + +impl FillLayer { + pub fn new(paint: Paint) -> Self { + Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![] } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeLayer { + pub paint: Paint, + /// Weight in points. + pub width: f64, + #[serde(default)] + pub cap: LineCap, + #[serde(default)] + pub join: LineJoin, + #[serde(default = "ten")] + pub miter_limit: f64, + #[serde(default)] + pub align: StrokeAlign, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_arrow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_arrow: Option, + /// Arrowhead scale in percent (start, end). + #[serde(default = "hundreds")] + pub arrow_scale: (f64, f64), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + /// Brush applied to the stroke (by brush name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brush: Option, + #[serde(default = "one")] + pub opacity: f32, + #[serde(default)] + pub blend: BlendMode, + #[serde(default = "yes")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, +} + +fn ten() -> f64 { + 10.0 +} +fn hundreds() -> (f64, f64) { + (100.0, 100.0) +} + +impl StrokeLayer { + pub fn new(paint: Paint, width: f64) -> Self { + Self { + paint, + width, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter_limit: 10.0, + align: StrokeAlign::Center, + dash: None, + start_arrow: None, + end_arrow: None, + arrow_scale: (100.0, 100.0), + profile: None, + brush: None, + opacity: 1.0, + blend: BlendMode::Normal, + visible: true, + effects: vec![], + } + } +} + +/// One entry of the appearance stack. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum AppearanceItem { + Fill(FillLayer), + Stroke(StrokeLayer), +} + +/// Appearance attributes. `items` is in paint order: `items[0]` is painted first (the bottom of the +/// Appearance panel list). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Appearance { + #[serde(default)] + pub items: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, +} + +impl Appearance { + /// Illustrator's "basic appearance": one fill below one stroke. + pub fn basic(fill: Paint, stroke: Paint, width: f64) -> Self { + Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], effects: vec![] } + } + /// White fill, 1 pt black stroke (the default for new art). + pub fn default_art() -> Self { + Self::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 1.0) + } + /// The topmost fill (what the Fill proxy shows). + pub fn fill(&self) -> Option<&FillLayer> { + self.items.iter().rev().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) + } + pub fn fill_mut(&mut self) -> Option<&mut FillLayer> { + self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) + } + /// The topmost stroke (what the Stroke proxy shows). + pub fn stroke(&self) -> Option<&StrokeLayer> { + self.items.iter().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) + } + pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> { + self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) + } + pub fn fill_paint(&self) -> Paint { + self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None) + } + pub fn stroke_paint(&self) -> Paint { + self.stroke().map(|s| s.paint.clone()).unwrap_or(Paint::None) + } + /// Set the top fill's paint, creating a fill if there is none. + pub fn set_fill(&mut self, p: Paint) { + match self.fill_mut() { + Some(f) => f.paint = p, + None => self.items.insert(0, AppearanceItem::Fill(FillLayer::new(p))), + } + } + /// Set the top stroke's paint, creating a 1 pt stroke if there is none. + pub fn set_stroke(&mut self, p: Paint) { + match self.stroke_mut() { + Some(s) => s.paint = p, + None => self.items.push(AppearanceItem::Stroke(StrokeLayer::new(p, 1.0))), + } + } + pub fn stroke_width(&self) -> f64 { + self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0) + } + /// Is this the basic one-fill-one-stroke appearance without effects? + pub fn is_basic(&self) -> bool { + self.effects.is_empty() + && self.items.len() <= 2 + && self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1 + && self.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.effects.is_empty() && f.opacity == 1.0 && f.blend == BlendMode::Normal, + AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal, + }) + } + /// Largest distance the painted area extends beyond the geometry (for visual bounds). + pub fn outset(&self) -> f64 { + self.items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(match s.align { + StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 }, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .fold(0.0, f64::max) + } + /// Scale stroke weights (Scale Strokes & Effects). + pub fn scale_strokes(&mut self, s: f64) { + for i in &mut self.items { + if let AppearanceItem::Stroke(st) = i { + st.width *= s; + if let Some(d) = &mut st.dash { + for v in &mut d.pattern { + *v *= s; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_appearance() { + let a = Appearance::default_art(); + assert!(a.is_basic()); + assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE)); + assert_eq!(a.stroke_width(), 1.0); + } + + #[test] + fn set_creates_missing() { + let mut a = Appearance::default(); + a.set_fill(Paint::solid(Color::BLACK)); + a.set_stroke(Paint::solid(Color::WHITE)); + assert_eq!(a.items.len(), 2); + assert!(matches!(a.items[0], AppearanceItem::Fill(_))); + } + + #[test] + fn profile_interp() { + let p = WidthProfile::lens(); + assert_eq!(p.at(0.5), (1.0, 1.0)); + assert_eq!(p.at(0.25), (0.5, 0.5)); + assert_eq!(p.at(2.0), (0.0, 0.0)); + } + + #[test] + fn outset_depends_on_align() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 5.0); + a.stroke_mut().unwrap().align = StrokeAlign::Outside; + assert_eq!(a.outset(), 10.0); + a.stroke_mut().unwrap().align = StrokeAlign::Inside; + assert_eq!(a.outset(), 0.0); + } +} diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs new file mode 100644 index 000000000..4b6253ccf --- /dev/null +++ b/crates/doc/src/hit.rs @@ -0,0 +1,256 @@ +//! Hit testing over the document tree. + +use drawcraft_geom::hit::{fill_contains, stroke_contains}; +use drawcraft_geom::{Point, Rect}; + +use crate::{Document, Node, NodeId, NodeKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HitKind { + Fill, + Stroke, + /// Hit a path outline in outline mode, or an unfilled path's edge. + Outline, + Bounds, +} + +/// The result of a hit test: the leaf that was hit and its ancestry (layer first). +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + pub leaf: NodeId, + pub ancestry: Vec, + pub kind: HitKind, +} + +impl Hit { + /// The object the Selection tool selects: the child of the layer (the outermost group). + /// Inside isolation mode (`scope`), the child of the isolated container instead. + pub fn top_object(&self, scope: Option) -> NodeId { + if let Some(s) = scope + && let Some(i) = self.ancestry.iter().position(|x| *x == s) + { + return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf); + } + // Skip layers and sublayers. + self.ancestry.get(1).copied().unwrap_or(self.leaf) + } +} + +/// Options for hit testing. +#[derive(Clone, Copy, Debug)] +pub struct HitOptions { + /// Tolerance in document units (screen tolerance / zoom). + pub tol: f64, + /// Outline mode: only outlines are hittable. + pub outline: bool, + /// "Object Selection by Path Only" preference. + pub path_only: bool, +} + +impl Default for HitOptions { + fn default() -> Self { + Self { tol: 3.0, outline: false, path_only: false } + } +} + +/// Topmost editable object under `p`. +pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { + let mut chain = Vec::new(); + for layer in doc.layers.iter().rev() { + if !layer.visible || layer.locked { + continue; + } + if let NodeKind::Layer { template: true, .. } = layer.kind { + continue; + } + chain.push(layer.id); + if let Some(h) = hit_children(layer, p, opt, &mut chain) { + return Some(h); + } + chain.pop(); + } + None +} + +fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec) -> Option { + let children = parent.children()?; + // A clip group only hits inside its clipping path. + if let NodeKind::Group { clip: true, .. } = parent.kind + && let Some(clip) = children.first() + && let Some(path) = clip.path_data() + && !fill_contains(&path.to_bezpath(), drawcraft_geom::FillRule::NonZero, p) + { + return None; + } + for c in children.iter().rev() { + if !c.visible || c.locked { + continue; + } + if let Some(b) = c.visual_bounds() + && !b.inflate(opt.tol, opt.tol).contains(p) + { + continue; + } + chain.push(c.id); + let hit = match &c.kind { + NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain), + _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }), + }; + if hit.is_some() { + return hit; + } + chain.pop(); + } + None +} + +fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { + match &n.kind { + NodeKind::Path { path, rule, .. } => { + let bp = path.to_bezpath(); + let sw = n.appearance.stroke_width(); + if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) { + return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke }); + } + let filled = !n.appearance.fill_paint().is_none(); + if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) { + return Some(HitKind::Fill); + } + None + } + NodeKind::Compound { children, rule } => { + let mut bp = drawcraft_geom::BezPath::new(); + for c in children { + if let Some(pd) = c.path_data() { + bp.extend(pd.to_bezpath()); + } + } + if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) { + return Some(HitKind::Stroke); + } + (!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) + } + NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) + } + _ => None, + } +} + +/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. +pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { + let mut out = Vec::new(); + let tops: Vec<&std::sync::Arc> = match scope.and_then(|s| doc.node(s)) { + Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(), + None => doc.layers.iter().filter(|l| l.visible && !l.locked).flat_map(|l| l.children().into_iter().flatten()).collect(), + }; + fn touches(n: &Node, r: Rect) -> bool { + match &n.kind { + NodeKind::Path { path, .. } => drawcraft_geom::hit::intersects_rect(path, r), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + children.iter().any(|c| c.visible && touches(c, r)) + } + _ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())), + } + } + fn collect_leaves(n: &Node, r: Rect, out: &mut Vec) { + match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + for c in children { + if c.visible && !c.locked { + collect_leaves(c, r, out); + } + } + } + _ => { + if touches(n, r) { + out.push(n.id); + } + } + } + } + for t in tops { + if !t.visible || t.locked { + continue; + } + if leaves { + collect_leaves(t, r, &mut out); + } else if touches(t, r) { + out.push(t.id); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, Node}; + use drawcraft_color::{Color, Paint}; + use drawcraft_geom::shapes; + use std::sync::Arc; + + #[test] + fn hits_topmost() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap(); + let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, b); + assert_eq!(h.kind, HitKind::Fill); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none()); + // Outline mode: interior misses. + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none()); + assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some()); + } + + #[test] + fn unfilled_interior_misses() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert( + Some(l), + 9, + Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + ) + .unwrap(); + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); + } + + #[test] + fn group_top_object() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + let g = d.alloc_id(); + let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert_eq!(h.top_object(None), g); + assert_eq!(h.top_object(Some(g)), a); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]); + assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); + } + + #[test] + fn locked_and_hidden_skip() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + d.node_mut(a).unwrap().locked = true; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + d.node_mut(a).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 000000000..788e46cfd --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,656 @@ +//! DrawCraft document model: artboards, layers and objects, appearance, swatches (pure data + serde). +//! +//! Documents are persistent trees: children are `Arc`, and edits go through +//! [`Document::node_mut`], which clones only the nodes on the path from the root to the edited node. +//! Keeping the previous `Document` value around is therefore a cheap undo snapshot. +#![forbid(unsafe_code)] + +pub mod appearance; +pub mod hit; +pub mod node; +pub mod selection; +pub mod text; + +use std::collections::BTreeMap; +use std::sync::Arc; + +pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +pub use drawcraft_color as color; +pub use drawcraft_geom as geom; +pub use hit::{Hit, HitKind}; +pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind}; +pub use selection::{AnchorRef, Selection}; +pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun}; + +use drawcraft_color::{Swatch, SwatchGroup}; +use drawcraft_geom::{Point, Rect}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum DocError { + #[error("no such object {0}")] + NoNode(NodeId), + #[error("object {0} cannot have children")] + NotContainer(NodeId), + #[error("{0}")] + Invalid(String), +} + +/// Measurement units (display only; the model is always in points). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + Points, + Picas, + Inches, + Millimeters, + Centimeters, + Pixels, + FeetInches, + Meters, + Yards, + Feet, +} + +impl Unit { + pub const ALL: [Unit; 10] = [ + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Pixels, + Unit::FeetInches, + Unit::Meters, + Unit::Yards, + Unit::Feet, + ]; + /// Points per unit. + pub fn points(self) -> f64 { + match self { + Unit::Points | Unit::Pixels => 1.0, + Unit::Picas => 12.0, + Unit::Inches => 72.0, + Unit::Millimeters => 72.0 / 25.4, + Unit::Centimeters => 72.0 / 2.54, + Unit::Meters => 72.0 / 0.0254, + Unit::Yards => 72.0 * 36.0, + Unit::Feet | Unit::FeetInches => 72.0 * 12.0, + } + } + pub fn suffix(self) -> &'static str { + match self { + Unit::Points => "pt", + Unit::Picas => "p", + Unit::Inches => "in", + Unit::Millimeters => "mm", + Unit::Centimeters => "cm", + Unit::Pixels => "px", + Unit::FeetInches | Unit::Feet => "ft", + Unit::Meters => "m", + Unit::Yards => "yd", + } + } + pub fn label(self) -> &'static str { + match self { + Unit::Points => "Points", + Unit::Picas => "Picas", + Unit::Inches => "Inches", + Unit::Millimeters => "Millimeters", + Unit::Centimeters => "Centimeters", + Unit::Pixels => "Pixels", + Unit::FeetInches => "Feet & Inches", + Unit::Meters => "Meters", + Unit::Yards => "Yards", + Unit::Feet => "Feet", + } + } + pub fn from_pt(self, v: f64) -> f64 { + v / self.points() + } + pub fn to_pt(self, v: f64) -> f64 { + v * self.points() + } + /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). + pub fn format(self, pt: f64) -> String { + let v = self.from_pt(pt); + let s = format!("{:.3}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + let s = if s == "-0" { "0" } else { s }; + format!("{s} {}", self.suffix()) + } + /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. + pub fn parse(self, s: &str) -> Option { + parse_measure(s, self) + } +} + +fn parse_measure(s: &str, default: Unit) -> Option { + let s = s.trim(); + // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). + for op in ['+', '-', '*', '/'] { + if let Some(i) = s[1.min(s.len())..].rfind(op).map(|i| i + 1.min(s.len())) { + let (l, r) = (&s[..i], &s[i + 1..]); + if l.trim().is_empty() { + continue; + } + let a = parse_measure(l, default)?; + return match op { + '+' => Some(a + parse_measure(r, default)?), + '-' => Some(a - parse_measure(r, default)?), + '*' => Some(a * r.trim().parse::().ok()?), + _ => { + let d = r.trim().parse::().ok()?; + if d == 0.0 { None } else { Some(a / d) } + } + }; + } + } + if let Some((p, pt)) = s.split_once('p') + && !p.is_empty() + && p.trim().parse::().is_ok() + && (pt.is_empty() || pt.trim().parse::().is_ok()) + && !s.ends_with("pt") + && !s.ends_with("px") + { + return Some(p.trim().parse::().ok()? * 12.0 + pt.trim().parse::().unwrap_or(0.0)); + } + let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len()); + let v: f64 = s[..num_end].trim().parse().ok()?; + let unit = match s[num_end..].trim() { + "" => default, + "pt" => Unit::Points, + "px" => Unit::Pixels, + "in" | "\"" => Unit::Inches, + "mm" => Unit::Millimeters, + "cm" => Unit::Centimeters, + "m" => Unit::Meters, + "ft" | "'" => Unit::Feet, + "yd" => Unit::Yards, + "pc" => Unit::Picas, + _ => return None, + }; + Some(unit.to_pt(v)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorMode { + #[default] + Rgb, + Cmyk, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Artboard { + pub id: u32, + pub name: String, + /// Artboard rectangle in document points. + pub rect: Rect, + #[serde(default)] + pub show_center_mark: bool, + #[serde(default)] + pub show_cross_hairs: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Guide { + /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). + pub vertical: bool, + pub pos: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GridPrefs { + pub spacing: f64, + pub subdivisions: u32, +} + +impl Default for GridPrefs { + fn default() -> Self { + Self { spacing: 72.0, subdivisions: 8 } + } +} + +/// A named graphic style (Graphic Styles panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GraphicStyle { + pub name: String, + pub appearance: Appearance, +} + +/// A symbol definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Symbol { + pub name: String, + pub art: Arc, +} + +/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageBlob { + pub mime: String, + #[serde(skip)] + pub bytes: Arc>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Document { + pub version: u32, + #[serde(default)] + pub title: String, + #[serde(default)] + pub units: Unit, + #[serde(default)] + pub color_mode: ColorMode, + pub artboards: Vec, + /// Top-level layers, bottom first. + pub layers: Vec>, + #[serde(default)] + pub swatches: Vec, + #[serde(default)] + pub swatch_groups: Vec, + #[serde(default)] + pub graphic_styles: Vec, + #[serde(default)] + pub symbols: Vec, + #[serde(default)] + pub guides: Vec, + #[serde(default)] + pub grid: GridPrefs, + #[serde(default = "ppi72")] + pub raster_effects_ppi: f64, + #[serde(default)] + pub images: BTreeMap, + next_id: u64, + /// Foreign data preserved on round-trip. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub unknown: serde_json::Map, +} + +fn ppi72() -> f64 { + 72.0 +} + +pub const FORMAT_VERSION: u32 = 1; + +impl Document { + /// A new document with one artboard of `size` and one layer ("Layer 1"). + pub fn new(width: f64, height: f64) -> Self { + let (swatches, swatch_groups) = drawcraft_color::default_swatches(); + let mut d = Self { + version: FORMAT_VERSION, + title: "Untitled-1".into(), + units: Unit::Points, + color_mode: ColorMode::Rgb, + artboards: vec![Artboard { + id: 1, + name: "Artboard 1".into(), + rect: Rect::new(0.0, 0.0, width, height), + show_center_mark: false, + show_cross_hairs: false, + }], + layers: vec![], + swatches, + swatch_groups, + graphic_styles: default_graphic_styles(), + symbols: vec![], + guides: vec![], + grid: GridPrefs::default(), + raster_effects_ppi: 72.0, + images: BTreeMap::new(), + next_id: 1, + unknown: Default::default(), + }; + let id = d.alloc_id(); + d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); + d + } + + /// Allocate a fresh node id. + pub fn alloc_id(&mut self) -> NodeId { + let id = NodeId(self.next_id); + self.next_id += 1; + id + } + pub fn peek_next_id(&self) -> u64 { + self.next_id + } + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + pub fn fix_next_id(&mut self) { + let mut max = 0; + for l in &self.layers { + l.walk(&mut |n| max = max.max(n.id.0)); + } + self.next_id = self.next_id.max(max + 1); + } + + /// Find a node anywhere in the tree. + pub fn node(&self, id: NodeId) -> Option<&Node> { + fn find(nodes: &[Arc], id: NodeId) -> Option<&Node> { + for n in nodes { + if n.id == id { + return Some(n); + } + if let Some(ch) = n.children() + && let Some(f) = find(ch, id) + { + return Some(f); + } + } + None + } + find(&self.layers, id) + } + + /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`). + pub fn index_path(&self, id: NodeId) -> Option> { + fn find(nodes: &[Arc], id: NodeId, path: &mut Vec) -> bool { + for (i, n) in nodes.iter().enumerate() { + path.push(i); + if n.id == id { + return true; + } + if let Some(ch) = n.children() + && find(ch, id, path) + { + return true; + } + path.pop(); + } + false + } + let mut p = vec![]; + find(&self.layers, id, &mut p).then_some(p) + } + + /// Ids from the top-level layer down to `id` inclusive. + pub fn ancestry(&self, id: NodeId) -> Option> { + let path = self.index_path(id)?; + let mut out = Vec::with_capacity(path.len()); + let mut nodes = &self.layers; + for &i in &path { + let n = &nodes[i]; + out.push(n.id); + if let Some(ch) = n.children() { + nodes = ch; + } + } + Some(out) + } + + pub fn parent_of(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + (a.len() >= 2).then(|| a[a.len() - 2]) + } + + /// The top-level layer that contains `id`. + pub fn layer_of(&self, id: NodeId) -> Option { + self.ancestry(id).and_then(|a| a.first().copied()) + } + + /// Mutable access to a node; clones the Arc path (copy-on-write). + pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let path = self.index_path(id)?; + let mut cur: &mut Arc = &mut self.layers[path[0]]; + for &i in &path[1..] { + cur = &mut Arc::make_mut(cur).children_mut()?[i]; + } + Some(Arc::make_mut(cur)) + } + + /// Children list of a container (or the top-level layer list for `None`). + pub fn children_mut(&mut self, parent: Option) -> Result<&mut Vec>, DocError> { + match parent { + None => Ok(&mut self.layers), + Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)), + } + } + pub fn children(&self, parent: Option) -> Option<&Vec>> { + match parent { + None => Some(&self.layers), + Some(p) => self.node(p)?.children(), + } + } + + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { + let id = node.id; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, Arc::new(node)); + Ok(id) + } + + /// Remove a node and return it. + pub fn remove(&mut self, id: NodeId) -> Result, DocError> { + let parent = self.parent_of(id); + let path = self.index_path(id).ok_or(DocError::NoNode(id))?; + let idx = *path.last().unwrap(); + let ch = self.children_mut(parent)?; + Ok(ch.remove(idx)) + } + + /// Move a node to `parent` at `index` (index measured after removal). + pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { + if let Some(p) = parent + && self.ancestry(p).is_some_and(|a| a.contains(&id)) + { + return Err(DocError::Invalid("cannot move an object into itself".into())); + } + let n = self.remove(id)?; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, n); + Ok(()) + } + + /// Position of `id` within its parent: (parent, index, sibling count). + pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { + let parent = self.parent_of(id); + let idx = *self.index_path(id)?.last()?; + let n = self.children(parent)?.len(); + Some((parent, idx, n)) + } + + /// Default target layer: the topmost visible, unlocked layer. + pub fn default_layer(&self) -> Option { + self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id) + } + + /// Add a new top-level layer above all others. + pub fn add_layer(&mut self, name: Option<&str>) -> NodeId { + let id = self.alloc_id(); + let n = self.layers.len(); + let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name()); + self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)))); + id + } + pub fn next_layer_name(&self) -> String { + let mut i = self.layers.len() + 1; + loop { + let name = format!("Layer {i}"); + if !self.layers.iter().any(|l| l.name.as_deref() == Some(&name)) { + return name; + } + i += 1; + } + } + + /// Visit every node depth first in paint order (bottom to top). + pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) { + for l in &self.layers { + l.walk(&mut f); + } + } + pub fn node_count(&self) -> usize { + self.layers.iter().map(|l| l.count()).sum() + } + /// Is the node (and every ancestor) visible and unlocked? + pub fn is_editable(&self, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) + } + pub fn is_visible(&self, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) + } + /// Colour of the layer containing `id` (selection highlight colour). + pub fn layer_color(&self, id: NodeId) -> [u8; 3] { + let l = self.layer_of(id).and_then(|l| self.node(l)); + match l.map(|n| &n.kind) { + Some(NodeKind::Layer { color, .. }) => color.rgb(), + _ => LAYER_COLORS[0].1, + } + } + /// Union of the geometric bounds of `ids`. + pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { + ids.iter() + .filter_map(|id| self.node(*id)) + .fold(None, |acc, n| drawcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() })) + } + /// Bounds of all art. + pub fn art_bounds(&self) -> Option { + self.layers.iter().fold(None, |acc, l| drawcraft_geom::union_opt(acc, l.visual_bounds())) + } + /// Artboard index containing point `p` (topmost = last). + pub fn artboard_at(&self, p: Point) -> Option { + self.artboards.iter().rposition(|a| a.rect.contains(p)) + } + pub fn next_artboard_id(&self) -> u32 { + self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1 + } + pub fn swatch(&self, name: &str) -> Option<&Swatch> { + self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name) + } + /// Deep-clone `node` with fresh ids for it and all descendants. + pub fn reid(&mut self, node: &Node) -> Node { + let mut n = node.clone(); + n.id = self.alloc_id(); + if let Some(ch) = n.children_mut() { + let old: Vec> = std::mem::take(ch); + let fresh: Vec> = old.iter().map(|c| Arc::new(self.reid(c))).collect(); + *n.children_mut().unwrap() = fresh; + } + n + } +} + +fn default_graphic_styles() -> Vec { + use drawcraft_color::{Color, Paint}; + vec![ + GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() }, + GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) }, + GraphicStyle { + name: "Heavy Ink".into(), + appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0), + }, + GraphicStyle { + name: "Sunshine".into(), + appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use drawcraft_geom::shapes; + + fn doc_with_rects() -> (Document, NodeId, NodeId) { + let mut d = Document::new(612.0, 792.0); + let layer = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap(); + (d, a, b) + } + + #[test] + fn new_document_has_layer_and_artboard() { + let d = Document::new(612.0, 792.0); + assert_eq!(d.layers.len(), 1); + assert_eq!(d.layers[0].display_name(), "Layer 1"); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); + } + + #[test] + fn find_and_paths() { + let (d, a, b) = doc_with_rects(); + assert_eq!(d.index_path(b), Some(vec![0, 1])); + assert_eq!(d.parent_of(a), Some(d.layers[0].id)); + assert_eq!(d.layer_of(b), Some(d.layers[0].id)); + assert!(d.node(NodeId(999)).is_none()); + assert_eq!(d.node_count(), 3); + } + + #[test] + fn copy_on_write_shares_untouched() { + let (d, a, b) = doc_with_rects(); + let mut e = d.clone(); + e.node_mut(a).unwrap().name = Some("A".into()); + // b's Arc is shared between both versions; a's is not. + let lb = &d.layers[0].children().unwrap()[1]; + let eb = &e.layers[0].children().unwrap()[1]; + assert!(Arc::ptr_eq(lb, eb)); + assert_eq!(d.node(a).unwrap().name, None); + assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A")); + let _ = b; + } + + #[test] + fn move_and_remove() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(d.index_path(a), Some(vec![0, 1])); + assert_eq!(d.index_path(b), Some(vec![0, 0])); + let r = d.remove(a).unwrap(); + assert_eq!(r.id, a); + assert!(d.node(a).is_none()); + assert!(d.move_node(layer, Some(layer), 0).is_err()); + } + + #[test] + fn layers_and_names() { + let mut d = Document::new(100.0, 100.0); + let l2 = d.add_layer(None); + assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2"); + assert_eq!(d.default_layer(), Some(l2)); + assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); + } + + #[test] + fn serde_roundtrip() { + let (d, _, _) = doc_with_rects(); + let s = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&s).unwrap(); + assert_eq!(back.node_count(), d.node_count()); + assert_eq!(back.peek_next_id(), d.peek_next_id()); + } + + #[test] + fn reid_gives_fresh_ids() { + let (mut d, a, b) = doc_with_rects(); + let g = d.alloc_id(); + let na = (*d.remove(a).unwrap()).clone(); + let nb = (*d.remove(b).unwrap()).clone(); + let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]); + let copy = d.reid(&group); + assert_ne!(copy.id, g); + let ids: Vec = copy.children().unwrap().iter().map(|c| c.id).collect(); + assert!(!ids.contains(&a) && !ids.contains(&b)); + } + + #[test] + fn units() { + assert_eq!(Unit::Inches.parse("1"), Some(72.0)); + assert_eq!(Unit::Points.parse("1in"), Some(72.0)); + assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0)); + assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); + assert_eq!(Unit::Points.parse("10+5"), Some(15.0)); + assert_eq!(Unit::Points.parse("100/4"), Some(25.0)); + assert_eq!(Unit::Points.parse("-5"), Some(-5.0)); + assert_eq!(Unit::Points.parse("abc"), None); + assert_eq!(Unit::Points.format(12.5), "12.5 pt"); + assert_eq!(Unit::Inches.format(144.0), "2 in"); + } +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs new file mode 100644 index 000000000..659744a8a --- /dev/null +++ b/crates/doc/src/node.rs @@ -0,0 +1,448 @@ +//! Document nodes. + +use std::sync::Arc; + +use drawcraft_color::BlendMode; +use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; +use serde::{Deserialize, Serialize}; + +use crate::appearance::Appearance; +use crate::text::TextObject; + +/// Stable per-document object id. Never reused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct NodeId(pub u64); + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0) + } +} + +/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LayerColor { + Preset(u8), + Custom([u8; 3]), +} + +/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue. +pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [ + ("Light Blue", [0x4f, 0x80, 0xff]), + ("Red", [0xff, 0x4f, 0x4f]), + ("Green", [0x4f, 0xff, 0x4f]), + ("Blue", [0x4f, 0x4f, 0xff]), + ("Yellow", [0xff, 0xff, 0x4f]), + ("Magenta", [0xff, 0x4f, 0xff]), + ("Cyan", [0x4f, 0xff, 0xff]), + ("Gray", [0x80, 0x80, 0x80]), + ("Black", [0x00, 0x00, 0x00]), + ("Orange", [0xff, 0x66, 0x00]), + ("Dark Green", [0x00, 0x80, 0x00]), + ("Teal", [0x00, 0x80, 0x80]), + ("Tan", [0xcc, 0x99, 0x66]), + ("Brown", [0x99, 0x33, 0x00]), + ("Violet", [0x99, 0x33, 0xff]), + ("Gold", [0xff, 0x99, 0x00]), + ("Dark Blue", [0x00, 0x00, 0x80]), + ("Pink", [0xff, 0x99, 0xcc]), + ("Lavender", [0x99, 0x99, 0xff]), + ("Brick Red", [0x99, 0x00, 0x00]), + ("Olive Green", [0x66, 0x66, 0x00]), + ("Peach", [0xff, 0x99, 0x99]), + ("Burgundy", [0x99, 0x00, 0x33]), + ("Grass Green", [0x99, 0xcc, 0x00]), + ("Ochre", [0x99, 0x66, 0x00]), + ("Purple", [0x66, 0x00, 0x99]), + ("Light Gray", [0xbb, 0xbb, 0xbb]), +]; + +impl LayerColor { + pub fn rgb(self) -> [u8; 3] { + match self { + LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1, + LayerColor::Custom(c) => c, + } + } +} + +/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these +/// while the shape stays live; editing anchors directly converts it to a plain path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "shape", rename_all = "lowercase")] +pub enum LiveShape { + Rectangle { + /// Untransformed width/height. + w: f64, + h: f64, + /// Corner radii: top-left, top-right, bottom-right, bottom-left. + radii: [f64; 4], + /// Maps the untransformed shape (origin at its top-left) into the document. + xf: Affine, + }, + Ellipse { + w: f64, + h: f64, + /// Pie start/end angles in degrees (0/360 = full ellipse). + pie: (f64, f64), + xf: Affine, + }, + Polygon { + radius: f64, + sides: u32, + xf: Affine, + }, + Line { + a: Point, + b: Point, + }, +} + +impl LiveShape { + pub fn label(&self) -> &'static str { + match self { + LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", + LiveShape::Rectangle { .. } => "Rectangle", + LiveShape::Ellipse { .. } => "Ellipse", + LiveShape::Polygon { .. } => "Polygon", + LiveShape::Line { .. } => "Line", + } + } + /// Regenerate the path. + pub fn to_path(&self) -> PathData { + match self { + LiveShape::Rectangle { w, h, radii, xf } => { + let r = Rect::new(0.0, 0.0, *w, *h); + let p = if radii.iter().all(|x| (x - radii[0]).abs() < 1e-9) { + shapes::rounded_rectangle(r, radii[0]) + } else { + // Per-corner radii: generate uniformly then adjust (approximation until M2.5). + shapes::rounded_rectangle(r, radii.iter().cloned().fold(0.0, f64::max)) + }; + p.transformed(*xf) + } + LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf), + LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf), + LiveShape::Line { a, b } => shapes::line(*a, *b), + } + } + pub fn transform(&mut self, a: Affine) { + match self { + LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf, + LiveShape::Line { a: p, b } => { + *p = a * *p; + *b = a * *b; + } + } + } + /// Rotation angle of the live shape in degrees (shown in the Properties panel). + pub fn angle_deg(&self) -> f64 { + match self { + LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { + let c = xf.as_coeffs(); + c[1].atan2(c[0]).to_degrees() + } + LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(), + } + } +} + +/// Embedded or linked raster image. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageObject { + /// Key into [`crate::Document::images`]. + pub key: String, + pub width: u32, + pub height: u32, + /// Maps pixel space (0..w, 0..h) into the document. + pub xf: Affine, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum NodeKind { + /// A top-level layer or sublayer. + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + }, + /// A group. With `clip`, the first (bottom-most) child is the clipping path. + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default)] + rule: FillRule, + #[serde(default, skip_serializing_if = "Option::is_none")] + live: Option, + /// This path acts as a clipping path inside a clip group. + #[serde(default)] + clipping: bool, + /// Guide (non-printing) path. + #[serde(default)] + guide: bool, + }, + /// Compound path: children are paths painted as one with the compound's appearance. + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + Image(ImageObject), + SymbolInstance { + symbol: String, + xf: Affine, + }, +} + +fn yes() -> bool { + true +} +fn one() -> f32 { + 1.0 +} + +/// One object in the tree. Children are `Arc`s so documents share structure between undo states. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Node { + pub id: NodeId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default = "yes")] + pub visible: bool, + #[serde(default)] + pub locked: bool, + #[serde(default = "one")] + pub opacity: f32, + #[serde(default)] + pub blend: BlendMode, + #[serde(default)] + pub isolate: bool, + #[serde(default)] + pub knockout: bool, + #[serde(default)] + pub appearance: Appearance, + pub kind: NodeKind, +} + +impl Node { + pub fn new(id: NodeId, kind: NodeKind) -> Self { + Self { + id, + name: None, + visible: true, + locked: false, + opacity: 1.0, + blend: BlendMode::Normal, + isolate: false, + knockout: false, + appearance: Appearance::default(), + kind, + } + } + pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { + let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }); + n.appearance = appearance; + n + } + pub fn group(id: NodeId, children: Vec>) -> Self { + Self::new(id, NodeKind::Group { children, clip: false }) + } + pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { + let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![] }); + n.name = Some(name.to_string()); + n + } + pub fn children(&self) -> Option<&Vec>> { + match &self.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children), + _ => None, + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec>> { + match &mut self.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children), + _ => None, + } + } + pub fn is_layer(&self) -> bool { + matches!(self.kind, NodeKind::Layer { .. }) + } + pub fn is_container(&self) -> bool { + self.children().is_some() + } + /// Kind name as the Layers panel / Properties panel shows it. + pub fn kind_label(&self) -> &'static str { + match &self.kind { + NodeKind::Layer { .. } => "Layer", + NodeKind::Group { clip: true, .. } => "Clip Group", + NodeKind::Group { .. } => "Group", + NodeKind::Path { live: Some(l), .. } => l.label(), + NodeKind::Path { guide: true, .. } => "Guide", + NodeKind::Path { .. } => "Path", + NodeKind::Compound { .. } => "Compound Path", + NodeKind::Text(_) => "Text", + NodeKind::Image(_) => "Image", + NodeKind::SymbolInstance { .. } => "Symbol", + } + } + /// Name shown in the Layers panel: explicit name or ``. + pub fn display_name(&self) -> String { + if let Some(n) = &self.name { + return n.clone(); + } + match &self.kind { + NodeKind::Text(t) => { + let s: String = t.plain_text().chars().take(32).collect(); + if s.is_empty() { "".into() } else { s } + } + _ => format!("<{}>", self.kind_label()), + } + } + /// Path data for path nodes. + pub fn path_data(&self) -> Option<&PathData> { + match &self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + pub fn path_data_mut(&mut self) -> Option<&mut PathData> { + match &mut self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. + pub fn geometric_bounds(&self) -> Option { + match &self.kind { + NodeKind::Path { path, .. } => path.bounds(), + NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children + .iter() + .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) + .fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.geometric_bounds())), + NodeKind::Text(t) => t.bounds(), + NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), + NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))), + } + } + /// Visual bounds (includes stroke outset). + pub fn visual_bounds(&self) -> Option { + match &self.kind { + NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.visual_bounds())) + } + _ => { + let o = self.appearance.outset(); + self.geometric_bounds().map(|b| b.inflate(o, o)) + } + } + } + /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. + /// `scale_strokes` also scales stroke weights by the transform's mean scale. + pub fn transform(&mut self, a: Affine, scale_strokes: bool) { + if scale_strokes { + let det = a.determinant().abs().sqrt(); + if (det - 1.0).abs() > 1e-9 { + self.appearance.scale_strokes(det); + } + } + transform_paints(&mut self.appearance, a); + match &mut self.kind { + NodeKind::Path { path, live, .. } => { + path.transform(a); + if let Some(l) = live { + l.transform(a); + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + Arc::make_mut(c).transform(a, scale_strokes); + } + } + NodeKind::Text(t) => t.transform(a), + NodeKind::Image(im) => im.xf = a * im.xf, + NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, + } + } + /// Visit this node and all descendants depth first (paint order). + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + f(self); + if let Some(ch) = self.children() { + for c in ch { + c.walk(f); + } + } + } + /// Count of nodes in this subtree. + pub fn count(&self) -> usize { + let mut n = 0; + self.walk(&mut |_| n += 1); + n + } +} + +fn transform_paints(ap: &mut Appearance, a: Affine) { + use crate::appearance::AppearanceItem; + use drawcraft_color::Paint; + for it in &mut ap.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = p + && let Some(geom) = &mut g.geom + { + geom.transform(a); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_live_shape_roundtrip() { + let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], xf: Affine::translate((5.0, 5.0)) }; + assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); + assert_eq!(l.label(), "Rectangle"); + } + + #[test] + fn transform_group_recurses() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); + g.transform(Affine::scale(2.0), true); + assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); + let child = &g.children().unwrap()[0]; + assert_eq!(child.appearance.stroke_width(), 2.0); + } + + #[test] + fn display_names() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert_eq!(p.display_name(), ""); + let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); + assert_eq!(l.display_name(), "Layer 1"); + } + + #[test] + fn visual_bounds_include_stroke() { + let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; + assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); + } +} diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs new file mode 100644 index 000000000..99bbc73a0 --- /dev/null +++ b/crates/doc/src/selection.rs @@ -0,0 +1,96 @@ +//! Selection state (objects and, for direct selection, individual anchors). + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{Document, NodeId}; + +/// (subpath index, anchor index) inside a path. +pub type AnchorRef = (usize, usize); + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Selection { + /// Selected objects in selection order. + pub objects: Vec, + /// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub anchors: BTreeMap>, + /// Key object for Align. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, +} + +impl Selection { + pub fn is_empty(&self) -> bool { + self.objects.is_empty() + } + pub fn len(&self) -> usize { + self.objects.len() + } + pub fn contains(&self, id: NodeId) -> bool { + self.objects.contains(&id) + } + pub fn clear(&mut self) { + self.objects.clear(); + self.anchors.clear(); + self.key = None; + } + pub fn set(&mut self, ids: impl IntoIterator) { + self.clear(); + for id in ids { + self.add(id); + } + } + pub fn add(&mut self, id: NodeId) { + if !self.objects.contains(&id) { + self.objects.push(id); + } + } + pub fn remove(&mut self, id: NodeId) { + self.objects.retain(|x| *x != id); + self.anchors.remove(&id); + if self.key == Some(id) { + self.key = None; + } + } + pub fn toggle(&mut self, id: NodeId) { + if self.contains(id) { self.remove(id) } else { self.add(id) } + } + /// Are some (not all) anchors of `id` direct-selected? + pub fn partial(&self, id: NodeId) -> Option<&BTreeSet> { + self.anchors.get(&id) + } + /// Drop ids that no longer exist or are no longer editable. + pub fn prune(&mut self, doc: &Document) { + self.objects.retain(|id| doc.node(*id).is_some()); + self.anchors.retain(|id, _| doc.node(*id).is_some()); + if self.key.is_some_and(|k| doc.node(k).is_none()) { + self.key = None; + } + } + /// Top-level ordering: selected ids sorted by paint order (bottom first). + pub fn in_paint_order(&self, doc: &Document) -> Vec { + let mut v: Vec<(Vec, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); + v.sort(); + v.into_iter().map(|(_, id)| id).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_ops() { + let mut s = Selection::default(); + s.add(NodeId(1)); + s.add(NodeId(1)); + s.toggle(NodeId(2)); + assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]); + s.toggle(NodeId(1)); + assert_eq!(s.objects, vec![NodeId(2)]); + s.clear(); + assert!(s.is_empty()); + } +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs new file mode 100644 index 000000000..87c719bb5 --- /dev/null +++ b/crates/doc/src/text.rs @@ -0,0 +1,193 @@ +//! Text objects (model only; layout and glyph outlines live in `drawcraft-text`). + +use drawcraft_color::{Color, Paint}; +use drawcraft_geom::{Affine, PathData, Point, Rect}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Justify { + #[default] + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +/// Character attributes (the Character panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CharStyle { + pub font_family: String, + #[serde(default = "regular")] + pub font_style: String, + /// Size in points. + pub size: f64, + /// Leading in points; None = Auto (120% of size). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub leading: Option, + /// Tracking in 1/1000 em. + #[serde(default)] + pub tracking: f64, + /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kerning: Option, + #[serde(default)] + pub baseline_shift: f64, + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + #[serde(default)] + pub rotation: f64, + pub fill: Paint, + #[serde(default)] + pub stroke: Paint, + #[serde(default)] + pub stroke_width: f64, + #[serde(default)] + pub underline: bool, + #[serde(default)] + pub strikethrough: bool, + #[serde(default)] + pub all_caps: bool, +} + +fn regular() -> String { + "Regular".into() +} +fn hundred() -> f64 { + 100.0 +} + +impl Default for CharStyle { + fn default() -> Self { + Self { + font_family: "Source Sans 3".into(), + font_style: "Regular".into(), + size: 12.0, + leading: None, + tracking: 0.0, + kerning: None, + baseline_shift: 0.0, + h_scale: 100.0, + v_scale: 100.0, + rotation: 0.0, + fill: Paint::solid(Color::BLACK), + stroke: Paint::None, + stroke_width: 0.0, + underline: false, + strikethrough: false, + all_caps: false, + } + } +} + +impl CharStyle { + pub fn effective_leading(&self) -> f64 { + self.leading.unwrap_or(self.size * 1.2) + } +} + +/// Paragraph attributes (the Paragraph panel). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ParaStyle { + #[serde(default)] + pub justify: Justify, + #[serde(default)] + pub left_indent: f64, + #[serde(default)] + pub right_indent: f64, + #[serde(default)] + pub first_line_indent: f64, + #[serde(default)] + pub space_before: f64, + #[serde(default)] + pub space_after: f64, + #[serde(default)] + pub hyphenate: bool, +} + +/// A run of text sharing one character style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextRun { + pub text: String, + pub style: CharStyle, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum TextKind { + /// Point type: anchored at the baseline origin of the first line. + Point, + /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). + Area { frame: PathData }, + /// Type on a path, starting at `start` (0..1 of the path length). + OnPath { path: PathData, start: f64 }, +} + +/// A text object. `runs` split into paragraphs at `\n`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextObject { + pub kind: TextKind, + /// Maps text space (origin = first baseline start for point type) to the document. + pub xf: Affine, + pub runs: Vec, + #[serde(default)] + pub para: ParaStyle, + /// Cached layout bounds in text space, filled in by the layout engine (not serialized). + #[serde(skip)] + pub cached_bounds: Option, +} + +impl TextObject { + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { + Self { + kind: TextKind::Point, + xf: Affine::translate(origin.to_vec2()), + runs: vec![TextRun { text: text.into(), style }], + para: ParaStyle::default(), + cached_bounds: None, + } + } + pub fn plain_text(&self) -> String { + self.runs.iter().map(|r| r.text.as_str()).collect() + } + pub fn first_style(&self) -> CharStyle { + self.runs.first().map(|r| r.style.clone()).unwrap_or_default() + } + /// Approximate bounds when no layout cache is available (0.55 em average advance). + pub fn estimate_bounds(&self) -> Rect { + let st = self.first_style(); + let text = self.plain_text(); + let lines: Vec<&str> = text.split('\n').collect(); + let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; + let lead = st.effective_leading(); + Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) + } + pub fn bounds(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), + } + } + pub fn transform(&mut self, a: Affine) { + self.xf = a * self.xf; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_text_basics() { + let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); + assert_eq!(t.plain_text(), "Hello"); + let b = t.bounds().unwrap(); + assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); + assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); + } +} diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml new file mode 100644 index 000000000..f46ea8dcc --- /dev/null +++ b/crates/effects/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "drawcraft-effects" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft live effects evaluated to geometry" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-pathops = { workspace = true } +kurbo = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs new file mode 100644 index 000000000..ffe061016 --- /dev/null +++ b/crates/effects/src/lib.rs @@ -0,0 +1,2 @@ +//! DrawCraft live effects evaluated to geometry +#![forbid(unsafe_code)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 000000000..e25df0364 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "drawcraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft engine: sessions, history, selection, the command registry" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-tools = { workspace = true } +drawcraft-text = { workspace = true } +drawcraft-render = { workspace = true } +drawcraft-pathops = { workspace = true } +drawcraft-svg = { workspace = true } +drawcraft-format = { workspace = true } +kurbo = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs new file mode 100644 index 000000000..8d1f19774 --- /dev/null +++ b/crates/engine/src/cmd/create.rs @@ -0,0 +1,236 @@ +//! Creating art: shapes, paths, text. + +use drawcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObject}; +use drawcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes}; +use serde_json::{Value, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle), + cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse), + cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon), + cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star), + cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), + cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), + cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc), + cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid), + cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid), + cmd!( + "path.create", + "Create Path", + [], + None, + "{anchors: [{x, y, in?: [x,y], out?: [x,y]}], closed?: bool, d?: SVG path data} → {id}", + has_doc, + path_create + ), + cmd!( + "text.create", + "Create Text", + [], + None, + "{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}", + has_doc, + text_create + ), + ] +} + +/// Insert a new object at the top of the insertion parent with the current paint; select it. +pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option) -> Result { + let appearance = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + add_node(s, label, kind, appearance, name) +} + +pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option) -> Result { + let parent = s.doc()?.insertion_parent(); + let id = s.edit(label, |d, sel| { + let id = d.alloc_id(); + let mut n = Node::new(id, kind); + n.appearance = appearance; + n.name = name; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn path_kind(path: PathData, live: Option) -> NodeKind { + NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false } +} + +fn rect_of(p: &Value, cmd: &str) -> Result { + let x = f64_req(p, "x", cmd)?; + let y = f64_req(p, "y", cmd)?; + let w = f64_req(p, "width", cmd)?; + let h = f64_req(p, "height", cmd)?; + if !(w.is_finite() && h.is_finite()) { + return Err(bad(cmd, "invalid size")); + } + Ok(Rect::new(x, y, x + w, y + h).abs()) +} + +fn rectangle(s: &mut Session, p: &Value) -> Result { + let r = rect_of(p, "shape.rectangle")?; + let radius = f64_or(p, "radius", 0.0).max(0.0); + let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii: [radius; 4], xf: Affine::translate(r.origin().to_vec2()) }; + let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" }; + add_art(s, label, path_kind(live.to_path(), Some(live)), None) +} + +fn ellipse(s: &mut Session, p: &Value) -> Result { + let r = rect_of(p, "shape.ellipse")?; + let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) }; + add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None) +} + +fn polygon(s: &mut Session, p: &Value) -> Result { + let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?); + let r = f64_req(p, "radius", "shape.polygon")?.abs(); + let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32; + let rot = f64_or(p, "rotation", 0.0); + let live = LiveShape::Polygon { radius: r, sides, xf: Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()) }; + add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None) +} + +fn star(s: &mut Session, p: &Value) -> Result { + let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?); + let r1 = f64_req(p, "radius1", "shape.star")?.abs(); + let r2 = f64_or(p, "radius2", r1 / 2.0).abs(); + let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32; + let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0)); + add_art(s, "Star", path_kind(path, None), None) +} + +fn line(s: &mut Session, p: &Value) -> Result { + let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?); + let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?); + let live = LiveShape::Line { a, b }; + // Lines are stroked, never filled (Illustrator ignores the fill for new open lines). + let appearance = Appearance::basic( + drawcraft_color::Paint::None, + if s.paint.stroke.is_none() { drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, + s.paint.stroke_width.max(0.1), + ); + add_node(s, "Line", path_kind(live.to_path(), Some(live)), appearance, None) +} + +fn spiral(s: &mut Session, p: &Value) -> Result { + let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?); + let path = shapes::spiral( + c, + f64_req(p, "radius", "shape.spiral")?, + f64_or(p, "decay", 80.0), + p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32, + bool_or(p, "clockwise", true), + ); + add_art(s, "Spiral", path_kind(path, None), None) +} + +fn arc(s: &mut Session, p: &Value) -> Result { + let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); + let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); + add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None) +} + +fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { + let parent = s.doc()?.insertion_parent(); + let ap = Appearance::basic(drawcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); + let id = s.edit(label, |d, sel| { + let children = paths + .into_iter() + .map(|pd| { + let id = d.alloc_id(); + std::sync::Arc::new(Node::path(id, pd, ap.clone())) + }) + .collect(); + let gid = d.alloc_id(); + d.insert(parent, usize::MAX, Node::group(gid, children))?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0 })) +} + +fn rect_grid(s: &mut Session, p: &Value) -> Result { + let r = rect_of(p, "shape.rectangularGrid")?; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true)) +} + +fn polar_grid(s: &mut Session, p: &Value) -> Result { + let r = rect_of(p, "shape.polarGrid")?; + let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad)) +} + +pub(crate) fn anchor_from_json(v: &Value) -> Option { + let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?); + let h_in = point_param(v, "in").unwrap_or(p); + let h_out = point_param(v, "out").unwrap_or(p); + let mut a = Anchor::with_handles(p, h_in, h_out); + if v.get("smooth").and_then(Value::as_bool) == Some(true) { + a.kind = AnchorKind::Smooth; + } + Some(a) +} + +fn path_create(s: &mut Session, p: &Value) -> Result { + let path = if let Some(d) = str_param(p, "d") { + let bp = drawcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?; + PathData::from_bezpath(&bp) + } else { + let anchors: Vec = p + .get("anchors") + .and_then(Value::as_array) + .ok_or_else(|| bad("path.create", "missing anchors"))? + .iter() + .filter_map(anchor_from_json) + .collect(); + if anchors.is_empty() { + return Err(bad("path.create", "need at least one anchor")); + } + PathData::single(SubPath::new(anchors, bool_or(p, "closed", false))) + }; + let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + // Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set. + if !path.is_closed() && ap.stroke_paint().is_none() && ap.fill_paint().is_none() { + ap.set_stroke(drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK)); + } + add_node(s, "Pen", path_kind(path, None), ap, None) +} + +fn text_create(s: &mut Session, p: &Value) -> Result { + let x = f64_req(p, "x", "text.create")?; + let y = f64_req(p, "y", "text.create")?; + let text = str_param(p, "text").unwrap_or(""); + let mut style = CharStyle::default(); + if let Some(sz) = p.get("size").and_then(Value::as_f64) { + style.size = sz.clamp(0.1, 1296.0); + } + if let Some(f) = str_param(p, "font") { + style.font_family = f.to_string(); + } + if let Some(f) = str_param(p, "style") { + style.font_style = f.to_string(); + } + style.fill = match p.get("color").and_then(color_value) { + Some(c) => drawcraft_color::Paint::solid(c), + None if !s.paint.fill.is_none() && s.paint.fill != drawcraft_color::Paint::solid(drawcraft_color::Color::WHITE) => s.paint.fill.clone(), + None => drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK), + }; + let mut t = TextObject::point(Point::new(x, y), text, style); + if let Some(a) = p.get("area") { + let w = f64_or(a, "width", 200.0); + let h = f64_or(a, "height", 100.0); + t.kind = drawcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; + } + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(lay.bounds); + add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 000000000..7e9f43fe9 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -0,0 +1,283 @@ +//! File (document-level) and Edit commands, plus document queries. + +use std::sync::Arc; + +use drawcraft_doc::{Document, Node, NodeId, Unit}; +use drawcraft_geom::{Affine, Vec2}; +use serde_json::{Value, json}; + +use super::*; +use crate::{EngineError, inspect}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.new", + "New…", + ["File"], + Some("Cmd+N"), + "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}", + always, + file_new + ), + cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), + cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), + cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))), + cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!( + "document.setUnits", + "Units", + ["File", "Document Setup"], + None, + "{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}", + has_doc, + set_units + ), + cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), + cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut), + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy), + cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{dx?, dy?}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset)), + cmd!("edit.pasteInFront", "Paste in Front", ["Edit"], Some("Cmd+F"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Front)), + cmd!("edit.pasteInBack", "Paste in Back", ["Edit"], Some("Cmd+B"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Back)), + cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace)), + cmd!("edit.pasteOnAllArtboards", "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clipboard, |s, p| paste( + s, + p, + PasteMode::AllArtboards + )), + cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear), + cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate), + ] +} + +fn file_new(s: &mut Session, p: &Value) -> Result { + let w = f64_or(p, "width", 612.0); + let h = f64_or(p, "height", 792.0); + if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { + return Err(bad("file.new", "width/height must be positive and within the canvas")); + } + let mut d = Document::new(w, h); + d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); + if let Some(u) = str_param(p, "units") { + d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?; + } + if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) { + d.color_mode = drawcraft_doc::ColorMode::Cmyk; + } + let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; + for i in 1..n { + let r = drawcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); + d.artboards.push(drawcraft_doc::Artboard { + id: (i + 1) as u32, + name: format!("Artboard {}", i + 1), + rect: r, + show_center_mark: false, + show_cross_hairs: false, + }); + } + let i = s.add_document(d, None); + Ok(json!({ "index": i })) +} + +pub(crate) fn parse_unit(u: &str) -> Option { + let n = u.to_ascii_lowercase(); + Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n) +} + +fn file_close(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + if !s.close_document(i) { + return Err(bad("file.close", "no such document")); + } + ok() +} + +fn doc_activate(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize; + if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) } +} + +fn doc_node(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + Ok(serde_json::to_value(n).unwrap_or(Value::Null)) +} + +fn set_units(s: &mut Session, p: &Value) -> Result { + let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?; + s.edit("Document Setup", |d, _| { + d.units = u; + Ok(()) + })?; + ok() +} + +fn undo(s: &mut Session, _: &Value) -> Result { + s.cancel_interaction()?; + let st = s.doc_mut()?; + let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; + let label = e.label.clone(); + st.history.redo.push(crate::HistoryEntry { + label: e.label, + doc: std::mem::replace(&mut st.doc, e.doc), + selection: std::mem::replace(&mut st.selection, e.selection), + }); + st.revision += 1; + st.selection.prune(&st.doc); + Ok(json!({ "undone": label })) +} + +fn redo(s: &mut Session, _: &Value) -> Result { + let st = s.doc_mut()?; + let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; + let label = e.label.clone(); + st.history.undo.push(crate::HistoryEntry { + label: e.label, + doc: std::mem::replace(&mut st.doc, e.doc), + selection: std::mem::replace(&mut st.selection, e.selection), + }); + st.revision += 1; + st.selection.prune(&st.doc); + Ok(json!({ "redone": label })) +} + +/// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +pub(crate) fn selected_roots(s: &Session) -> Result> { + let st = s.doc()?; + let sel = st.selection.in_paint_order(&st.doc); + Ok(sel + .iter() + .copied() + .filter(|id| { + let anc = st.doc.ancestry(*id).unwrap_or_default(); + !anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a)) + }) + .filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())) + .collect()) +} + +fn copy(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let st = s.doc()?; + let nodes: Vec = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect(); + let n = nodes.len(); + s.clipboard = nodes; + Ok(json!({ "copied": n })) +} + +fn cut(s: &mut Session, p: &Value) -> Result { + copy(s, p)?; + clear(s, &json!({})) +} + +fn clear(s: &mut Session, p: &Value) -> Result { + // Direct-selected anchors: delete those anchors instead of whole objects. + if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() { + return super::path::delete_anchors(s, p); + } + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + s.edit("Clear", |d, sel| { + for id in &ids { + if d.node(*id).is_some_and(|n| n.is_layer()) { + continue; + } + let _ = d.remove(*id); + } + sel.clear(); + Ok(()) + })?; + ok() +} + +enum PasteMode { + Offset, + Front, + Back, + InPlace, + AllArtboards, +} + +fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result { + let clip = s.clipboard.clone(); + let off = s.prefs.paste_offset; + let st = s.doc()?; + let parent = st.insertion_parent(); + // Front/back: relative to the selection (top-most / bottom-most selected object). + let anchor = match mode { + PasteMode::Front | PasteMode::Back => { + let order = st.selection.in_paint_order(&st.doc); + let a = if matches!(mode, PasteMode::Front) { order.last() } else { order.first() }; + a.and_then(|id| st.doc.position(*id)) + } + _ => None, + }; + let artboards: Vec = st.doc.artboards.iter().map(|a| a.rect).collect(); + let dx = f64_or(p, "dx", off); + let dy = f64_or(p, "dy", off); + let label = match mode { + PasteMode::Front => "Paste in Front", + PasteMode::Back => "Paste in Back", + PasteMode::InPlace => "Paste in Place", + PasteMode::AllArtboards => "Paste on All Artboards", + PasteMode::Offset => "Paste", + }; + let ids = s.edit(label, |d, sel| { + let mut new_ids = vec![]; + let placements: Vec = match mode { + PasteMode::Offset => vec![Affine::translate((dx, dy))], + PasteMode::AllArtboards => { + let src = d.artboards.first().map(|a| a.rect.origin()).unwrap_or_default(); + artboards.iter().map(|r| Affine::translate(r.origin() - src)).collect() + } + _ => vec![Affine::IDENTITY], + }; + for xf in placements { + for (k, n) in clip.iter().enumerate() { + let mut c = d.reid(n); + if xf != Affine::IDENTITY { + c.transform(xf, false); + } + let (par, idx) = match anchor { + Some((par, i, _)) => (par, if matches!(mode, PasteMode::Front) { i + 1 + k } else { i + k }), + None => (parent, usize::MAX), + }; + new_ids.push(d.insert(par, idx, c)?); + } + } + sel.set(new_ids.iter().copied()); + Ok(new_ids) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let roots = selected_roots(s)?; + let dx = f64_or(p, "dx", 0.0); + let dy = f64_or(p, "dy", 0.0); + let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +/// Duplicate `roots` directly above each original, transform the copies, select them. +pub(crate) fn duplicate_in(d: &mut Document, sel: &mut drawcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result> { + let mut out = vec![]; + for id in roots { + let Some((par, idx, _)) = d.position(*id) else { continue }; + let Some(n) = d.node(*id).cloned() else { continue }; + let mut c = d.reid(&n); + c.transform(xf, true); + out.push(d.insert(par, idx + 1, c)?); + } + sel.set(out.iter().copied()); + Ok(out) +} + +#[allow(dead_code)] +pub(crate) fn arc_nodes(v: Vec) -> Vec> { + v.into_iter().map(Arc::new).collect() +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs new file mode 100644 index 000000000..8ec74d365 --- /dev/null +++ b/crates/engine/src/cmd/layer.rs @@ -0,0 +1,254 @@ +//! Layers panel, artboards. + +use drawcraft_doc::{Artboard, LayerColor, NodeId, NodeKind}; +use drawcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer), + cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer), + cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer), + cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer), + cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current), + cmd!( + "layer.setProps", + "Layer Options…", + ["Window", "Layers"], + None, + "{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}", + has_doc, + set_props + ), + cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect), + cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on), + cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node), + cmd!( + "artboard.new", + "New Artboard", + ["Window", "Artboards"], + None, + "{x?, y?, width?, height?, name?} (default: right of the last)", + has_doc, + artboard_new + ), + cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete), + cmd!( + "artboard.setProps", + "Artboard Options…", + ["Window", "Artboards"], + None, + "{index, name?, x?, y?, width?, height?}", + has_doc, + artboard_set + ), + cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art), + cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel), + ] +} + +fn new_layer(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").map(str::to_string); + let st = s.doc()?; + let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i); + let id = s.edit("New Layer", |d, _| { + let id = d.add_layer(name.as_deref()); + if let Some(i) = cur { + d.move_node(id, None, i + 1)?; + } + Ok(id) + })?; + s.doc_mut()?.active_layer = Some(id); + Ok(json!({ "id": id.0 })) +} + +fn new_sublayer(s: &mut Session, p: &Value) -> Result { + let parent = id_param(p, "parent").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?; + let name = str_param(p, "name").map(str::to_string); + let id = s.edit("New Sublayer", |d, _| { + let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0); + let id = d.alloc_id(); + let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8); + let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count())); + d.insert(Some(parent), usize::MAX, drawcraft_doc::Node::layer(id, &nm, color))?; + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn delete_layer(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.delete", "no layer"))?; + let st = s.doc()?; + if st.doc.layers.len() == 1 && st.doc.layers[0].id == id { + return Err(EngineError::Other("a document needs at least one layer".into())); + } + s.edit("Delete Layer", |d, sel| { + d.remove(id)?; + sel.prune(d); + Ok(()) + })?; + let st = s.doc_mut()?; + if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) { + st.active_layer = st.doc.default_layer(); + } + ok() +} + +fn duplicate_layer(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.duplicate", "no layer"))?; + let nid = s.edit("Duplicate Layer", |d, _| { + let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let mut c = d.reid(&n); + c.name = Some(format!("{} copy", n.display_name())); + Ok(d.insert(par, idx + 1, c)?) + })?; + Ok(json!({ "id": nid.0 })) +} + +fn set_current(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?; + let st = s.doc_mut()?; + if !st.doc.node(id).is_some_and(|n| n.is_layer()) { + return Err(bad("layer.setCurrent", "not a layer")); + } + st.active_layer = Some(id); + st.revision += 1; + ok() +} + +fn set_props(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?; + s.edit("Layer Options", |d, sel| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + if let Some(v) = str_param(p, "name") { + n.name = Some(v.to_string()); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + n.visible = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + n.locked = v; + } + if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind { + if let Some(v) = p.get("template").and_then(Value::as_bool) { + *template = v; + } + if let Some(v) = p.get("printable").and_then(Value::as_bool) { + *printable = v; + } + if let Some(v) = p.get("color").and_then(Value::as_u64) { + *color = LayerColor::Preset((v % 27) as u8); + } + } + // Hidden or locked content can't stay selected. + let hidden: Vec = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect(); + for h in hidden { + sel.remove(h); + } + Ok(()) + })?; + ok() +} + +fn collect(s: &mut Session, _: &Value) -> Result { + let ids = super::edit::selected_roots(s)?; + let lid = s.edit("Collect in New Layer", |d, _| { + let lid = d.add_layer(None); + for id in &ids { + d.move_node(*id, Some(lid), usize::MAX)?; + } + Ok(lid) + })?; + Ok(json!({ "id": lid.0 })) +} + +fn select_all_on(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?; + let ids: Vec = s + .doc()? + .doc + .node(id) + .and_then(|n| n.children()) + .map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()) + .unwrap_or_default(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + ok() +} + +fn move_node(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?; + let parent = id_param(p, "parent"); + let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize; + if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) { + return Err(bad("node.move", "only layers can be at the top level")); + } + s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?; + ok() +} + +fn rect_from(p: &Value, default: Rect) -> Rect { + let x = f64_or(p, "x", default.x0); + let y = f64_or(p, "y", default.y0); + let w = f64_or(p, "width", default.width()).max(1.0); + let h = f64_or(p, "height", default.height()).max(1.0); + Rect::new(x, y, x + w, y + h) +} + +fn artboard_new(s: &mut Session, p: &Value) -> Result { + let d0 = &s.doc()?.doc; + let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); + let r = rect_from(p, def); + let idx = s.edit("New Artboard", |d, _| { + let id = d.next_artboard_id(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1)); + d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false }); + Ok(d.artboards.len() - 1) + })?; + Ok(json!({ "index": idx })) +} + +fn artboard_delete(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize; + s.edit("Delete Artboard", |d, _| { + if d.artboards.len() <= 1 { + return Err(EngineError::Other("a document needs at least one artboard".into())); + } + if i >= d.artboards.len() { + return Err(EngineError::Other("no such artboard".into())); + } + d.artboards.remove(i); + Ok(()) + })?; + ok() +} + +fn artboard_set(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + s.edit("Artboard Options", |d, _| { + let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + a.rect = rect_from(p, a.rect); + if let Some(n) = str_param(p, "name") { + a.name = n.to_string(); + } + Ok(()) + })?; + ok() +} + +fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?; + s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()})) +} + +fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let st = s.doc()?; + let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?; + s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 000000000..66a4e52e8 --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,194 @@ +//! The command registry. Ids follow Illustrator's menu structure. + +mod create; +mod edit; +mod layer; +mod object; +mod paint; +mod path; +mod select; + +use drawcraft_color::Color; +use drawcraft_doc::NodeId; +use drawcraft_geom::{Affine, Point}; +use serde::Serialize; +use serde_json::Value; + +use crate::{EngineError, Result, Session}; + +pub type Run = fn(&mut Session, &Value) -> Result; +pub type Enabled = fn(&Session) -> std::result::Result<(), String>; + +/// Metadata + implementation for one command. +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus. + pub menu: &'static [&'static str], + /// Default shortcut (`Cmd+Shift+]`), mapped per platform by the UI. + pub shortcut: Option<&'static str>, + /// Human/agent-readable parameter description. + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + /// Record in the journal (false for queries and selection-only helpers). + pub journal: bool, +} + +/// Serializable command metadata. +#[derive(Clone, Debug, Serialize)] +pub struct CommandInfo { + pub id: &'static str, + pub label: &'static str, + pub menu: Vec<&'static str>, + pub shortcut: Option<&'static str>, + pub params: &'static str, + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +impl CommandSpec { + pub fn info(&self, s: &Session) -> CommandInfo { + let e = (self.enabled)(s); + CommandInfo { + id: self.id, + label: self.label, + menu: self.menu.to_vec(), + shortcut: self.shortcut, + params: self.params, + enabled: e.is_ok(), + disabled_reason: e.err(), + } + } +} + +// ---------- enablement predicates ---------- + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} +pub fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} +pub fn has_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().unwrap().selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } +} +pub fn has_multi(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().unwrap().selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } +} +pub fn can_undo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) +} +pub fn can_redo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into()) +} +pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } +} + +macro_rules! cmd { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + }; + (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false } + }; +} +pub(crate) use cmd; + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SPECS.get_or_init(|| { + let mut v = Vec::new(); + v.extend(edit::specs()); + v.extend(create::specs()); + v.extend(object::specs()); + v.extend(path::specs()); + v.extend(select::specs()); + v.extend(paint::specs()); + v.extend(layer::specs()); + v + }) +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + command_specs().iter().find(|c| c.id == id) +} + +// ---------- param helpers ---------- + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} +pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { + p.get(key).and_then(Value::as_f64).unwrap_or(default) +} +pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) +} +pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { + p.get(key).and_then(Value::as_bool).unwrap_or(default) +} +pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + p.get(key).and_then(Value::as_str) +} +pub(crate) fn id_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_u64).map(NodeId) +} +pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) +} +pub(crate) fn point_param(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array()?; + Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) +} +/// `[a, b, c, d, e, f]` affine coefficients. +pub fn matrix_param(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array()?; + if a.len() != 6 { + return None; + } + let mut c = [0.0; 6]; + for (i, v) in a.iter().enumerate() { + c[i] = v.as_f64()?; + } + Some(Affine::new(c)) +} +/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`. +pub(crate) fn color_value(v: &Value) -> Option { + match v { + Value::String(s) => Color::from_hex(s), + Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), + Value::Object(o) => { + if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) { + let f = |v: &Value| v.as_f64().map(|x| if x > 1.0 { x / 100.0 } else { x } as f32); + return Some(Color::cmyk(f(c)?, f(m)?, f(y)?, f(k)?)); + } + if let Some(g) = o.get("gray") { + let g = g.as_f64()?; + return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32)); + } + serde_json::from_value(v.clone()).ok() + } + _ => None, + } +} + +/// Objects a command targets: explicit `ids` param or the selection. +pub(crate) fn targets(s: &Session, p: &Value) -> Result> { + if let Some(ids) = ids_param(p, "ids") { + return Ok(ids); + } + if let Some(id) = id_param(p, "id") { + return Ok(vec![id]); + } + Ok(s.doc()?.selection.objects.clone()) +} + +pub(crate) fn ok() -> Result { + Ok(Value::Null) +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs new file mode 100644 index 000000000..1937f1ffd --- /dev/null +++ b/crates/engine/src/cmd/object.rs @@ -0,0 +1,790 @@ +//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation, +//! align & distribute, object properties. + +use std::sync::Arc; + +use drawcraft_color::{BlendMode, Paint}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use drawcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use super::edit::{duplicate_in, selected_roots}; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.transform", + "Transform", + [], + None, + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?} apply an affine to the selection (or ids)", + has_doc, + transform + ), + cmd!("object.move", "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?}", has_selection, move_cmd), + cmd!( + "object.rotate", + "Rotate…", + ["Object", "Transform"], + None, + "{angle: deg (counter-clockwise), origin?: [x,y], copy?}", + has_selection, + rotate + ), + cmd!("object.scale", "Scale…", ["Object", "Transform"], None, "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool}", has_selection, scale), + cmd!( + "object.reflect", + "Reflect…", + ["Object", "Transform"], + None, + "{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?}", + has_selection, + reflect + ), + cmd!( + "object.shear", + "Shear…", + ["Object", "Transform"], + None, + "{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?}", + has_selection, + shear + ), + cmd!("object.transformAgain", "Transform Again", ["Object", "Transform"], Some("Cmd+D"), "{}", has_selection, transform_again), + cmd!( + "object.nudge", + "Nudge", + [], + None, + "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment", + has_selection, + nudge + ), + cmd!("object.arrange.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| arrange( + s, + Arrange::Front + )), + cmd!("object.arrange.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| arrange( + s, + Arrange::Forward + )), + cmd!("object.arrange.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| arrange( + s, + Arrange::Backward + )), + cmd!("object.arrange.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| arrange( + s, + Arrange::Back + )), + cmd!("object.arrange.sendToCurrentLayer", "Send to Current Layer", ["Object", "Arrange"], None, "{}", has_selection, send_to_current_layer), + cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{} → {id}", has_selection, group), + cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{}", has_selection, ungroup), + cmd!("object.lock", "Selection", ["Object", "Lock"], Some("Cmd+2"), "{}", has_selection, lock), + cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all), + cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide), + cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all), + cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make), + cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release), + cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make), + cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release), + cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id}", has_doc, isolate), + cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation), + cmd!( + "object.setProps", + "Object Properties", + [], + None, + "{ids?|id?, name?, visible?, locked?, opacity?: 0..1 or %, blend?: \"Multiply\"…, isolate?, knockout?}", + has_doc, + set_props + ), + cmd!( + "object.align", + "Align", + ["Window", "Align"], + None, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\"}", + has_selection, + align + ), + cmd!( + "object.distribute", + "Distribute", + ["Window", "Align"], + None, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\"}", + has_multi, + distribute + ), + cmd!( + "object.distributeSpacing", + "Distribute Spacing", + ["Window", "Align"], + None, + "{axis: \"horizontal\"|\"vertical\", spacing?: pt}", + has_multi, + distribute_spacing + ), + cmd!( + "object.setBounds", + "Set Bounds", + [], + None, + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?} (Transform panel)", + has_selection, + set_bounds + ), + cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape), + cmd!("object.setLiveShape", "Live Shape Properties", [], None, "{id?, radius?: pt (all corners), sides?: n}", has_selection, set_live_shape), + ] +} + +fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { + if let Some(o) = point_param(p, "origin") { + return Ok(o); + } + s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) +} + +/// Apply `xf` to `ids` (copy = duplicate first). Records Transform Again. +pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, copy: bool) -> Result { + let scale_strokes = s.prefs.scale_strokes; + let ids = s.edit(label, |d, sel| { + let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; + for id in &targets { + if let Some(n) = d.node_mut(*id) { + n.transform(xf, scale_strokes); + } + } + Ok(targets) + })?; + let st = s.doc_mut()?; + if st.interaction.is_none() { + st.last_transform = Some((xf, copy)); + } + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn transform(s: &mut Session, p: &Value) -> Result { + let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + apply_transform(s, "Transform", ids, m, bool_or(p, "copy", false)) +} + +fn move_cmd(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), bool_or(p, "copy", false)) +} + +fn nudge(s: &mut Session, p: &Value) -> Result { + let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; + let dx = f64_or(p, "dx", 0.0) * k; + let dy = f64_or(p, "dy", 0.0) * k; + if !s.doc()?.selection.anchors.is_empty() { + return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy })); + } + let ids = selected_roots(s)?; + apply_transform(s, "Move", ids, Affine::translate((dx, dy)), bool_or(p, "copy", false)) +} + +fn about(o: Point, a: Affine) -> Affine { + Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) +} + +fn rotate(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let o = origin_of(s, p, &ids)?; + // Illustrator angles are counter-clockwise; y is down, so negate. + let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians())); + apply_transform(s, "Rotate", ids, a, bool_or(p, "copy", false)) +} + +fn scale(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let o = origin_of(s, p, &ids)?; + let sx = f64_req(p, "sx", "object.scale")? / 100.0; + let sy = f64_or(p, "sy", sx * 100.0) / 100.0; + if sx == 0.0 || sy == 0.0 { + return Err(bad("object.scale", "scale must be non-zero")); + } + let prev = s.prefs.scale_strokes; + if let Some(b) = p.get("strokes").and_then(Value::as_bool) { + s.prefs.scale_strokes = b; + } + let r = apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), bool_or(p, "copy", false)); + s.prefs.scale_strokes = prev; + r +} + +fn reflect(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let o = origin_of(s, p, &ids)?; + let m = match p.get("axis") { + Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), + Some(Value::Number(n)) => { + let t = -n.as_f64().unwrap_or(90.0).to_radians(); + Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) + } + _ => Affine::scale_non_uniform(-1.0, 1.0), + }; + apply_transform(s, "Reflect", ids, about(o, m), bool_or(p, "copy", false)) +} + +fn shear(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let o = origin_of(s, p, &ids)?; + let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); + let m = + if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; + apply_transform(s, "Shear", ids, about(o, m), bool_or(p, "copy", false)) +} + +fn transform_again(s: &mut Session, _: &Value) -> Result { + let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?; + let ids = selected_roots(s)?; + apply_transform(s, "Transform Again", ids, m, copy) +} + +enum Arrange { + Front, + Forward, + Backward, + Back, +} + +fn arrange(s: &mut Session, how: Arrange) -> Result { + let mut ids = selected_roots(s)?; + if matches!(how, Arrange::Forward | Arrange::Front) { + ids.reverse(); + } + let label = match how { + Arrange::Front => "Bring to Front", + Arrange::Forward => "Bring Forward", + Arrange::Backward => "Send Backward", + Arrange::Back => "Send to Back", + }; + s.edit(label, |d, _| { + let n = ids.len(); + for (k, id) in ids.iter().enumerate() { + let Some((par, idx, len)) = d.position(*id) else { continue }; + let to = match how { + Arrange::Front => len - 1 - k, + Arrange::Back => k, + Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)), + Arrange::Backward => idx.saturating_sub(1).max(k.min(n)), + }; + if to != idx { + d.move_node(*id, par, to)?; + } + } + Ok(()) + })?; + ok() +} + +fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let layer = s.doc()?.active_layer.ok_or_else(|| EngineError::Other("no current layer".into()))?; + s.edit("Send to Current Layer", |d, _| { + for id in &ids { + d.move_node(*id, Some(layer), usize::MAX)?; + } + Ok(()) + })?; + ok() +} + +fn group(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; + let gid = s.edit("Group", |d, sel| { + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let gid = d.alloc_id(); + d.insert(par, idx + 1, Node::group(gid, vec![]))?; + for id in &ids { + d.move_node(*id, Some(gid), usize::MAX)?; + } + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": gid.0 })) +} + +fn ungroup(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Ungroup", |d, sel| { + let mut new_sel = vec![]; + for id in &ids { + let Some(n) = d.node(*id) else { continue }; + let NodeKind::Group { children, clip } = &n.kind else { + new_sel.push(*id); + continue; + }; + let children = children.clone(); + let clip = *clip; + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let opacity = n.opacity; + d.remove(*id)?; + for (k, c) in children.into_iter().enumerate() { + let mut c = (*c).clone(); + if clip && k == 0 { + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = false; + } + c.appearance = Appearance::default(); + } + c.opacity *= opacity; + new_sel.push(c.id); + d.insert(par, idx + k, c)?; + } + } + sel.set(new_sel); + Ok(()) + })?; + ok() +} + +fn lock(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Lock", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.locked = true; + } + } + sel.clear(); + Ok(()) + })?; + ok() +} + +fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if !n.is_layer() && f(n) { + v.push(n.id) + } + }); + v +} + +fn unlock_all(s: &mut Session, _: &Value) -> Result { + let ids = collect_flag(&s.doc()?.doc, |n| n.locked); + s.edit("Unlock All", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.locked = false; + } + } + sel.set(ids.iter().copied()); + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +fn hide(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Hide", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.visible = false; + } + } + sel.clear(); + Ok(()) + })?; + ok() +} + +fn show_all(s: &mut Session, _: &Value) -> Result { + let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); + s.edit("Show All", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.visible = true; + } + } + sel.set(ids.iter().copied()); + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +fn compound_make(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; + let id = s.edit("Make Compound Path", |d, sel| { + let appearance = d.node(top).map(|n| n.appearance.clone()).unwrap_or_default(); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let mut paths: Vec> = vec![]; + for id in &ids { + let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; + match &n.kind { + NodeKind::Path { .. } => paths.push(Arc::new(n)), + NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()), + NodeKind::Group { .. } => { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) { + paths.push(Arc::new(c.clone())) + } + }); + } + _ => return Err(EngineError::Other("compound paths can only contain paths".into())), + } + } + let cid = d.alloc_id(); + let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + c.appearance = appearance; + d.insert(par, idx + 1, c)?; + for id in &ids { + d.remove(*id)?; + } + let ch = d.node_mut(cid).unwrap().children_mut().unwrap(); + for mut p in paths { + let pn = Arc::make_mut(&mut p); + pn.appearance = Appearance::default(); + if let NodeKind::Path { live, .. } = &mut pn.kind { + *live = None; + } + ch.push(p); + } + sel.set([cid]); + Ok(cid) + })?; + Ok(json!({ "id": id.0 })) +} + +fn compound_release(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Release Compound Path", |d, sel| { + let mut out = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned() else { continue }; + let NodeKind::Compound { children, .. } = &n.kind else { continue }; + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + d.remove(*id)?; + for (k, c) in children.iter().enumerate() { + let mut c = (**c).clone(); + c.appearance = n.appearance.clone(); + out.push(c.id); + d.insert(par, idx + k, c)?; + } + } + sel.set(out); + Ok(()) + })?; + ok() +} + +fn clip_make(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let top = *ids.last().unwrap(); + let gid = s.edit("Make Clipping Mask", |d, sel| { + if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) { + return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into())); + } + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let gid = d.alloc_id(); + d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?; + d.move_node(top, Some(gid), 0)?; + for id in &ids[..ids.len() - 1] { + d.move_node(*id, Some(gid), usize::MAX)?; + } + if let Some(c) = d.node_mut(top) { + c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = true; + } + } + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": gid.0 })) +} + +fn clip_release(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Release Clipping Mask", |d, _| { + for id in &ids { + let clip_child = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Group { children, clip: true }) => children.first().map(|c| c.id), + _ => continue, + }; + if let Some(NodeKind::Group { clip, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *clip = false; + } + if let Some(c) = clip_child.and_then(|c| d.node_mut(c)) + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; + } + } + Ok(()) + })?; + ok() +} + +fn isolate(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id") + .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) + .ok_or_else(|| bad("object.isolate", "missing id"))?; + let st = s.doc_mut()?; + if st.doc.node(id).is_none_or(|n| !n.is_container()) { + return Err(bad("object.isolate", "only groups and layers can be isolated")); + } + st.isolation = Some(id); + st.selection.clear(); + st.revision += 1; + ok() +} + +fn exit_isolation(s: &mut Session, _: &Value) -> Result { + let st = s.doc_mut()?; + if let Some(i) = st.isolation.take() { + st.selection.set([i]); + } + st.revision += 1; + ok() +} + +fn set_props(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let opacity = p.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32); + let blend = match str_param(p, "blend") { + Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), + None => None, + }; + s.edit("Object Properties", |d, _| { + for id in &ids { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + if let Some(v) = str_param(p, "name") { + n.name = if v.is_empty() { None } else { Some(v.to_string()) }; + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + n.visible = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + n.locked = v; + } + if let Some(v) = opacity { + n.opacity = v; + } + if let Some(b) = blend { + n.blend = b; + } + if let Some(v) = p.get("isolate").and_then(Value::as_bool) { + n.isolate = v; + } + if let Some(v) = p.get("knockout").and_then(Value::as_bool) { + n.knockout = v; + } + } + Ok(()) + })?; + ok() +} + +fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result { + let st = s.doc()?; + match str_param(p, "to") { + Some("artboard") => { + let b = st.doc.bounds_of(ids, false).unwrap_or_default(); + let i = st.doc.artboard_at(b.center()).unwrap_or(0); + Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b)) + } + Some("key") => { + let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; + st.doc.node(k).and_then(|n| n.geometric_bounds()).ok_or(EngineError::NoNode(k)) + } + _ => st.doc.bounds_of(ids, false).ok_or_else(|| EngineError::Other("nothing to align".into())), + } +} + +fn align(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let r = reference_rect(s, p, &ids)?; + let h = str_param(p, "horizontal"); + let v = str_param(p, "vertical"); + let key = s.doc()?.selection.key; + let moves: Vec<(NodeId, Vec2)> = { + let d = &s.doc()?.doc; + ids.iter() + .filter(|id| Some(**id) != key || str_param(p, "to") != Some("key")) + .filter_map(|id| { + let b = d.node(*id)?.geometric_bounds()?; + let dx = match h { + Some("left") => r.x0 - b.x0, + Some("center") => r.center().x - b.center().x, + Some("right") => r.x1 - b.x1, + _ => 0.0, + }; + let dy = match v { + Some("top") => r.y0 - b.y0, + Some("center") => r.center().y - b.center().y, + Some("bottom") => r.y1 - b.y1, + _ => 0.0, + }; + Some((*id, Vec2::new(dx, dy))) + }) + .collect() + }; + s.edit("Align", |d, _| { + for (id, dv) in &moves { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(*dv), false); + } + } + Ok(()) + })?; + ok() +} + +fn distribute(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let d0 = &s.doc()?.doc; + let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect(); + let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) { + (Some(h), _) => ( + true, + match h { + "left" => |r: &Rect, _| r.x0, + "right" => |r: &Rect, _| r.x1, + _ => |r: &Rect, _| r.center().x, + }, + ), + (_, Some(v)) => ( + false, + match v { + "top" => |r: &Rect, _| r.y0, + "bottom" => |r: &Rect, _| r.y1, + _ => |r: &Rect, _| r.center().y, + }, + ), + _ => return Err(bad("object.distribute", "give horizontal or vertical")), + }; + items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); + let n = items.len(); + if n < 3 { + return ok(); + } + let first = key(&items[0].1, horiz); + let last = key(&items[n - 1].1, horiz); + let moves: Vec<(NodeId, Vec2)> = items + .iter() + .enumerate() + .map(|(i, (id, r))| { + let target = first + (last - first) * i as f64 / (n - 1) as f64; + let delta = target - key(r, horiz); + (*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }) + }) + .collect(); + s.edit("Distribute", |d, _| { + for (id, dv) in &moves { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(*dv), false); + } + } + Ok(()) + })?; + ok() +} + +fn distribute_spacing(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let horiz = str_param(p, "axis") != Some("vertical"); + let d0 = &s.doc()?.doc; + let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect(); + items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); + let n = items.len(); + let size = |r: &Rect| if horiz { r.width() } else { r.height() }; + let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; + let gap = match p.get("spacing").and_then(Value::as_f64) { + Some(g) => g, + None => { + let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 }; + (span - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64 + } + }; + let mut pos = start(&items[0].1); + let mut moves = vec![]; + for (id, r) in &items { + let delta = pos - start(r); + moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })); + pos += size(r) + gap; + } + s.edit("Distribute Spacing", |d, _| { + for (id, dv) in &moves { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(*dv), false); + } + } + Ok(()) + })?; + ok() +} + +fn set_bounds(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let b = s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let rp = drawcraft_geom::reference_point(b, refi); + let mut w = f64_or(p, "width", b.width()); + let mut h = f64_or(p, "height", b.height()); + if bool_or(p, "proportional", false) { + if p.get("width").is_some() && b.width() > 0.0 { + h = b.height() * w / b.width(); + } else if p.get("height").is_some() && b.height() > 0.0 { + w = b.width() * h / b.height(); + } + } + let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 }; + let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 }; + let scale = about(rp, Affine::scale_non_uniform(sx, sy)); + let new_rp = scale * rp; + let tx = f64_or(p, "x", new_rp.x) - new_rp.x; + let ty = f64_or(p, "y", new_rp.y) - new_rp.y; + apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, false) +} + +fn expand_shape(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Expand Shape", |d, _| { + for id in &ids { + if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *live = None; + } + } + Ok(()) + })?; + ok() +} + +fn set_live_shape(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit("Live Shape", |d, _| { + for id in &ids { + let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + match live { + drawcraft_doc::LiveShape::Rectangle { radii, .. } => { + if let Some(r) = p.get("radius").and_then(Value::as_f64) { + *radii = [r.max(0.0); 4]; + } + } + drawcraft_doc::LiveShape::Polygon { sides, .. } => { + if let Some(n) = p.get("sides").and_then(Value::as_u64) { + *sides = n.clamp(3, 1000) as u32; + } + } + _ => {} + } + *path = live.to_path(); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs new file mode 100644 index 000000000..623d942a4 --- /dev/null +++ b/crates/engine/src/cmd/paint.rs @@ -0,0 +1,494 @@ +//! Fill/stroke, Stroke panel, Appearance, Transparency, Swatches, Graphic Styles. + +use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; +use drawcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, FillLayer, LineCap, LineJoin, NodeKind, StrokeAlign, StrokeLayer}; +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "paint.setFill", + "Fill", + [], + None, + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default", + has_doc, + |s, p| set_paint(s, p, true) + ), + cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)), + cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap), + cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint), + cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| { + s.fill_active = !s.fill_active; + Ok(json!({ "fillActive": s.fill_active })) + }), + cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| { + let f = s.fill_active; + set_paint(s, &json!({"none": true}), f) + }), + cmd!( + "stroke.set", + "Stroke Options", + ["Window", "Stroke"], + None, + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + has_doc, + stroke_set + ), + cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)), + cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)), + cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance), + cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic), + cmd!( + "appearance.setItem", + "Appearance Item", + [], + None, + "{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack", + has_selection, + set_item + ), + cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), + cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), + cmd!( + "transparency.set", + "Transparency", + ["Window", "Transparency"], + None, + "{opacity?: 0..100, blend?: name, isolate?, knockout?}", + has_selection, + transparency + ), + cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), + cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), + cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new), + cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), + ] +} + +/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. +pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { + if bool_or(p, "none", false) { + return Ok(Some(Paint::None)); + } + if let Some(name) = str_param(p, "swatch") { + let st = s.doc()?; + let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; + let mut paint = sw.paint.clone(); + if sw.global + && let Paint::Solid { swatch, .. } = &mut paint + { + *swatch = Some(name.to_string()); + } + return Ok(Some(paint)); + } + if let Some(g) = p.get("gradient") { + let kind = match str_param(g, "kind") { + Some("radial") => GradientKind::Radial, + _ => GradientKind::Linear, + }; + let mut grad = Gradient { kind, ..Default::default() }; + if let Some(stops) = g.get("stops").and_then(Value::as_array) { + grad.stops = stops + .iter() + .filter_map(|st| { + Some(drawcraft_color::GradientStop { + offset: st.get("offset")?.as_f64()? as f32, + color: color_value(st.get("color")?)?, + opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32, + midpoint: 0.5, + }) + }) + .collect(); + grad.sort(); + } + let mut gp = GradientPaint::new(grad); + gp.angle = f64_or(g, "angle", 0.0); + return Ok(Some(Paint::Gradient(Box::new(gp)))); + } + if let Some(c) = p.get("color") { + let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; + return Ok(Some(Paint::solid(c))); + } + Ok(None) +} + +/// Leaf objects whose appearance should change for `ids` (groups apply to children). +fn paint_targets(s: &Session, p: &Value) -> Result> { + let ids = targets(s, p)?; + 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), + } + } + // Don't recurse into compound children (the compound owns the appearance). + let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); + out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); + Ok(out) +} + +fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { + let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?; + if fill { + s.paint.fill = paint.clone(); + } else { + s.paint.stroke = paint.clone(); + } + s.fill_active = fill; + let ids = paint_targets(s, p)?; + if ids.is_empty() { + return ok(); + } + s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + if fill { + r.style.fill = paint.clone(); + } else { + r.style.stroke = paint.clone(); + if r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } + } + } + continue; + } + if fill { + n.appearance.set_fill(paint.clone()); + } else { + n.appearance.set_stroke(paint.clone()); + } + } + Ok(()) + })?; + ok() +} + +fn swap(s: &mut Session, _: &Value) -> Result { + std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); + let ids = paint_targets(s, &json!({}))?; + if !ids.is_empty() { + s.edit("Swap Fill and Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let f = n.appearance.fill_paint(); + let st = n.appearance.stroke_paint(); + n.appearance.set_fill(st); + n.appearance.set_stroke(f); + } + } + Ok(()) + })?; + } + ok() +} + +fn default_paint(s: &mut Session, _: &Value) -> Result { + s.paint.fill = Paint::solid(Color::WHITE); + s.paint.stroke = Paint::solid(Color::BLACK); + s.paint.stroke_width = 1.0; + let ids = paint_targets(s, &json!({}))?; + if !ids.is_empty() { + s.edit("Default Fill and Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) + && !matches!(n.kind, NodeKind::Text(_)) + { + n.appearance = Appearance::default_art(); + } + } + Ok(()) + })?; + } + ok() +} + +fn stroke_set(s: &mut Session, p: &Value) -> Result { + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + s.paint.stroke_width = w.max(0.0); + } + let ids = paint_targets(s, p)?; + if ids.is_empty() { + return ok(); + } + let arrow = |k: &str| -> Result>> { + match p.get(k) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(n)) if n == "none" => Ok(Some(None)), + Some(v) => { + serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}"))) + } + } + }; + let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); + s.edit("Stroke", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none() { + n.appearance.set_stroke(Paint::solid(Color::BLACK)); + } + let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + st.width = w.max(0.0); + } + match str_param(p, "cap") { + Some("round") => st.cap = LineCap::Round, + Some("square") | Some("projecting") => st.cap = LineCap::Square, + Some("butt") => st.cap = LineCap::Butt, + _ => {} + } + match str_param(p, "join") { + Some("round") => st.join = LineJoin::Round, + Some("bevel") => st.join = LineJoin::Bevel, + Some("miter") => st.join = LineJoin::Miter, + _ => {} + } + if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { + st.miter_limit = m.clamp(1.0, 500.0); + } + match str_param(p, "align") { + Some("inside") => st.align = StrokeAlign::Inside, + Some("outside") => st.align = StrokeAlign::Outside, + Some("center") => st.align = StrokeAlign::Center, + _ => {} + } + match p.get("dash") { + Some(Value::Null) => st.dash = None, + Some(Value::Array(a)) => { + let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); + st.dash = if pattern.is_empty() { + None + } else { + Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) + }; + } + _ => {} + } + if let Some(a) = sa { + st.start_arrow = a; + } + if let Some(a) = ea { + st.end_arrow = a; + } + match str_param(p, "profile") { + Some("uniform") => st.profile = None, + Some("lens") => st.profile = Some(drawcraft_doc::WidthProfile::lens()), + Some("taperStart") => st.profile = Some(drawcraft_doc::WidthProfile::taper_start()), + Some("taperEnd") => st.profile = Some(drawcraft_doc::WidthProfile::taper_end()), + _ => {} + } + } + Ok(()) + })?; + ok() +} + +fn add_item(s: &mut Session, fill: bool) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let item = if fill { + AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint())) + } else { + AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0))) + }; + n.appearance.items.push(item); + } + } + Ok(()) + })?; + ok() +} + +fn clear_appearance(s: &mut Session, _: &Value) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit("Clear Appearance", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + } + } + Ok(()) + })?; + ok() +} + +fn reduce_basic(s: &mut Session, _: &Value) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit("Reduce to Basic Appearance", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let f = n.appearance.fill_paint(); + let st = n.appearance.stroke().cloned(); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; + if let Some(mut st) = st { + st.effects.clear(); + st.opacity = 1.0; + st.blend = BlendMode::Normal; + n.appearance.items.push(AppearanceItem::Stroke(st)); + } + } + } + Ok(()) + })?; + ok() +} + +fn set_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize; + let paint = paint_from(s, p)?; + let ids = paint_targets(s, p)?; + let blend = str_param(p, "blend").and_then(BlendMode::parse); + s.edit("Appearance", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(item) = n.appearance.items.get_mut(idx) else { continue }; + let (pp, op, bl, vis) = match item { + AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible), + AppearanceItem::Stroke(st) => { + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + st.width = w.max(0.0); + } + (&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible) + } + }; + if let Some(pa) = &paint { + *pp = pa.clone(); + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + *op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32; + } + if let Some(b) = blend { + *bl = b; + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + *vis = v; + } + } + Ok(()) + })?; + ok() +} + +fn remove_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize; + let ids = paint_targets(s, p)?; + s.edit("Remove Item", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) + && idx < n.appearance.items.len() + { + n.appearance.items.remove(idx); + } + } + Ok(()) + })?; + ok() +} + +fn add_effect(s: &mut Session, p: &Value) -> Result { + let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); + let params = p.get("params").cloned().unwrap_or(json!({})); + let ids = targets(s, p)?; + s.edit("Add Effect", |d, _| { + for nid in &ids { + if let Some(n) = d.node_mut(*nid) { + n.appearance.effects.push(drawcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true }); + } + } + Ok(()) + })?; + ok() +} + +fn transparency(s: &mut Session, p: &Value) -> Result { + let mut q = p.clone(); + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + q["opacity"] = json!(o / 100.0); + } + s.execute("object.setProps", &q) +} + +fn style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; + let ap = s + .doc()? + .doc + .graphic_styles + .iter() + .find(|g| g.name == name) + .map(|g| g.appearance.clone()) + .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; + let ids = paint_targets(s, p)?; + s.edit("Apply Graphic Style", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.appearance = ap.clone(); + } + } + Ok(()) + })?; + ok() +} + +fn style_new(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = *st.selection.objects.first().unwrap(); + let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); + s.edit("New Graphic Style", |d, _| { + d.graphic_styles.push(drawcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn swatch_new(s: &mut Session, p: &Value) -> Result { + let paint = match paint_from(s, p)? { + Some(x) => x, + None => s.paint.fill.clone(), + }; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => match &paint { + Paint::Solid { color, .. } => { + let [r, g, b] = color.to_rgb(); + format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) + } + _ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1), + }, + }; + let global = bool_or(p, "global", false); + s.edit("New Swatch", |d, _| { + d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint, global, spot: false }); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn swatch_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string(); + s.edit("Delete Swatch", |d, _| { + d.swatches.retain(|sw| sw.name != name); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| sw.name != name); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs new file mode 100644 index 000000000..91c6fa5be --- /dev/null +++ b/crates/engine/src/cmd/path.rs @@ -0,0 +1,472 @@ +//! Path editing commands (used by the Pen and Direct Selection tools and Object → Path). + +use std::collections::BTreeSet; + +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; +use serde_json::{Value, json}; + +use super::create::anchor_from_json; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "path.appendAnchor", + "Add Anchor (Pen)", + [], + None, + "{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath", + has_doc, + append_anchor + ), + cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close), + cmd!( + "path.moveAnchors", + "Move Anchors", + [], + None, + "{dx, dy} move direct-selected anchors (whole paths if fully selected)", + has_selection, + move_anchors + ), + cmd!( + "path.setHandle", + "Move Direction Handle", + [], + None, + "{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}", + has_doc, + set_handle + ), + cmd!( + "path.setAnchors", + "Set Anchors", + [], + None, + "{id, subpaths: [{anchors: [{x,y,in?,out?}], closed}]} replace a path's geometry", + has_doc, + set_anchors + ), + cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse), + cmd!( + "path.join", + "Join", + ["Object", "Path"], + Some("Cmd+J"), + "{} join two open paths' nearest endpoints, or close a single open path", + has_selection, + join + ), + cmd!( + "path.average", + "Average…", + ["Object", "Path"], + Some("Cmd+Alt+J"), + "{axis?: \"horizontal\"|\"vertical\"|\"both\"}", + has_selection, + average + ), + cmd!( + "path.convertAnchors", + "Convert Anchor Points", + [], + None, + "{to: \"corner\"|\"smooth\"} (Control bar convert buttons)", + has_selection, + convert_anchors + ), + cmd!( + "path.deleteAnchors", + "Remove Anchor Points", + ["Object", "Path"], + None, + "{} delete direct-selected anchors", + has_selection, + delete_anchors + ), + cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor), + cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors), + ] +} + +fn path_mut(d: &mut drawcraft_doc::Document, id: NodeId) -> Result<&mut PathData> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *live = None; // editing anchors expands a live shape + Ok(path) + } + _ => Err(EngineError::Other(format!("object {id} is not a path"))), + } +} + +fn append_anchor(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?; + s.edit("Pen", |d, sel| { + let path = path_mut(d, id)?; + match path.subpaths.last_mut() { + Some(sp) if !sp.closed => sp.anchors.push(a), + _ => path.subpaths.push(SubPath::new(vec![a], false)), + } + sel.set([id]); + let last = path.subpaths.len() - 1; + let ai = path.subpaths[last].anchors.len() - 1; + sel.anchors.insert(id, BTreeSet::from([(last, ai)])); + Ok(()) + })?; + ok() +} + +fn close(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let h_in = point_param(p, "in"); + let independent = bool_or(p, "independent", false); + s.edit("Close Path", |d, sel| { + let path = path_mut(d, id)?; + let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + sp.closed = true; + if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) { + first.h_in = h; + if !independent { + first.h_out = first.p - (h - first.p); + first.kind = AnchorKind::Smooth; + } + } + sel.set([id]); + Ok(()) + })?; + ok() +} + +pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { + let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let st = s.doc()?; + let sel = st.selection.clone(); + let scale_strokes = s.prefs.scale_strokes; + let _ = scale_strokes; + s.edit("Move", |doc, _| { + for id in &sel.objects { + match sel.anchors.get(id) { + Some(set) => { + let path = path_mut(doc, *id)?; + for &(si, ai) in set { + if let Some(a) = path.anchor_mut(si, ai) { + a.translate(d); + } + } + } + None => { + if let Some(n) = doc.node_mut(*id) { + n.transform(drawcraft_geom::Affine::translate(d), false); + } + } + } + } + Ok(()) + })?; + ok() +} + +fn set_handle(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize; + let out = str_param(p, "which") != Some("in"); + let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); + let independent = bool_or(p, "independent", false); + s.edit("Reshape", |d, _| { + let path = path_mut(d, id)?; + let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; + if independent { + a.kind = AnchorKind::Corner; + } + let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) }; + *moved = pos; + if a.kind == AnchorKind::Smooth { + // Keep the opposite handle collinear, preserving its length. + let len = (*other - a.p).hypot(); + let dir = a.p - pos; + let l = dir.hypot(); + if l > 1e-9 { + *other = a.p + dir * (len / l); + } + } + Ok(()) + })?; + ok() +} + +fn set_anchors(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; + let data = PathData::new( + subs.iter() + .map(|sp| { + SubPath::new( + sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(), + bool_or(sp, "closed", false), + ) + }) + .collect(), + ); + s.edit("Reshape", |d, _| { + *path_mut(d, id)? = data; + Ok(()) + })?; + ok() +} + +fn selected_paths(s: &Session) -> Result> { + let st = s.doc()?; + Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect()) +} + +fn reverse(s: &mut Session, _: &Value) -> Result { + let ids = selected_paths(s)?; + s.edit("Reverse Path Direction", |d, _| { + for id in &ids { + path_mut(d, *id)?.reverse(); + } + Ok(()) + })?; + ok() +} + +fn join(s: &mut Session, _: &Value) -> Result { + let ids = selected_paths(s)?; + match ids.as_slice() { + [one] => { + let id = *one; + s.edit("Join", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + // Merge coincident end points. + if sp.anchors.len() > 2 && sp.anchors[0].p.distance(sp.anchors.last().unwrap().p) < 1e-6 { + let last = sp.anchors.pop().unwrap(); + sp.anchors[0].h_in = last.h_in; + } + sp.closed = true; + Ok(()) + })?; + } + [a, b, ..] => { + let (a, b) = (*a, *b); + s.edit("Join", |d, sel| { + let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; + let pa = path_mut(d, a)?; + let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else { + return Err(EngineError::Other("join needs open paths".into())); + }; + let mut x = pa.subpaths[sa].clone(); + let mut y = pb.subpaths[sb].clone(); + let (xf, xl) = (x.anchors[0].p, x.anchors.last().unwrap().p); + let (yf, yl) = (y.anchors[0].p, y.anchors.last().unwrap().p); + // Pick the closest pair of ends. + let cands = + [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)]; + let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap(); + if rx { + x.reverse(); + } + if ry { + y.reverse(); + } + if x.anchors.last().unwrap().p.distance(y.anchors[0].p) < 1e-6 { + let first = y.anchors.remove(0); + x.anchors.last_mut().unwrap().h_out = first.h_out; + } + x.anchors.extend(y.anchors); + pa.subpaths[sa] = x; + d.remove(b)?; + sel.set([a]); + Ok(()) + })?; + } + [] => return Err(EngineError::Other("select paths to join".into())), + } + ok() +} + +type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>; + +fn anchor_targets(s: &Session) -> Result { + let st = s.doc()?; + let mut out = vec![]; + for id in &st.selection.objects { + let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue }; + let v: Vec<(usize, usize)> = match st.selection.anchors.get(id) { + Some(set) => set.iter().copied().collect(), + None => pd.anchors().map(|(s, a, _)| (s, a)).collect(), + }; + out.push((*id, v)); + } + Ok(out) +} + +fn average(s: &mut Session, p: &Value) -> Result { + let t = anchor_targets(s)?; + let d0 = &s.doc()?.doc; + let pts: Vec = t + .iter() + .flat_map(|(id, v)| v.iter().filter_map(move |(si, ai)| d0.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p))) + .collect(); + if pts.is_empty() { + return ok(); + } + let c = Point::new(pts.iter().map(|p| p.x).sum::() / pts.len() as f64, pts.iter().map(|p| p.y).sum::() / pts.len() as f64); + let axis = str_param(p, "axis").unwrap_or("both").to_string(); + s.edit("Average", |d, _| { + for (id, v) in &t { + let path = path_mut(d, *id)?; + for &(si, ai) in v { + if let Some(a) = path.anchor_mut(si, ai) { + let target = match axis.as_str() { + "horizontal" => Point::new(a.p.x, c.y), + "vertical" => Point::new(c.x, a.p.y), + _ => c, + }; + a.translate(target - a.p); + } + } + } + Ok(()) + })?; + ok() +} + +fn convert_anchors(s: &mut Session, p: &Value) -> Result { + let t = anchor_targets(s)?; + let smooth = str_param(p, "to") == Some("smooth"); + s.edit("Convert Anchor Points", |d, _| { + for (id, v) in &t { + let path = path_mut(d, *id)?; + for &(si, ai) in v { + let sp = &path.subpaths[si]; + let n = sp.anchors.len(); + let prev = if ai > 0 { + Some(sp.anchors[ai - 1].p) + } else if sp.closed { + Some(sp.anchors[n - 1].p) + } else { + None + }; + let next = if ai + 1 < n { + Some(sp.anchors[ai + 1].p) + } else if sp.closed { + Some(sp.anchors[0].p) + } else { + None + }; + let a: &mut Anchor = &mut path.subpaths[si].anchors[ai]; + if smooth { + // Handles parallel to prev→next, a third of the neighbour distances. + let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p)); + let dir = nn - pp; + let l = dir.hypot(); + if l > 1e-9 { + let u = dir / l; + a.h_in = a.p - u * (a.p.distance(pp) / 3.0); + a.h_out = a.p + u * (a.p.distance(nn) / 3.0); + a.kind = AnchorKind::Smooth; + } + } else { + a.retract(); + } + } + } + Ok(()) + })?; + ok() +} + +pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let sel = st.selection.anchors.clone(); + s.edit("Clear", |d, selection| { + for (id, set) in &sel { + let remove_node = { + let path = path_mut(d, *id)?; + // Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete). + for &(si, ai) in set.iter().rev() { + if let Some(sp) = path.subpaths.get_mut(si) + && ai < sp.anchors.len() + { + if sp.closed { + sp.anchors.rotate_left(ai + 1); + sp.anchors.pop(); + sp.closed = false; + } else { + sp.anchors.remove(ai); + } + } + } + path.subpaths.retain(|s| s.anchors.len() >= 2); + path.subpaths.is_empty() + }; + if remove_node { + d.remove(*id)?; + } + } + selection.clear(); + Ok(()) + })?; + ok() +} + +fn insert_anchor(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize; + let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0); + let idx = s.edit("Add Anchor Point", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if seg >= sp.segment_count() { + return Err(EngineError::Other("no such segment".into())); + } + Ok(sp.insert_anchor(seg, t)) + })?; + Ok(json!({ "anchor": idx })) +} + +fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { + let sel = s.doc()?.selection.anchors.clone(); + s.edit("Cut Path", |d, _| { + for (id, set) in &sel { + let path = path_mut(d, *id)?; + let mut out = vec![]; + for (si, sp) in path.subpaths.iter().enumerate() { + let cuts: Vec = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect(); + if cuts.is_empty() { + out.push(sp.clone()); + continue; + } + let mut anchors = sp.anchors.clone(); + if sp.closed { + anchors.rotate_left(cuts[0]); + anchors.push(anchors[0]); + } + let base = if sp.closed { cuts[0] } else { 0 }; + let n = sp.anchors.len(); + let rel: Vec = cuts.iter().map(|c| (c + n - base) % n).collect(); + let mut cur = vec![]; + for (i, a) in anchors.iter().enumerate() { + cur.push(*a); + if rel.contains(&(i % n)) && cur.len() > 1 { + out.push(SubPath::new(std::mem::take(&mut cur), false)); + cur.push(*a); + } + } + if cur.len() > 1 { + out.push(SubPath::new(cur, false)); + } + } + path.subpaths = out; + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs new file mode 100644 index 000000000..dd5f59792 --- /dev/null +++ b/crates/engine/src/cmd/select.rs @@ -0,0 +1,267 @@ +//! Select menu. + +use std::collections::BTreeSet; + +use drawcraft_doc::{Document, Node, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!("select.all", "All", ["Select"], Some("Cmd+A"), "{}", has_doc, all), + cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard), + cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none), + cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect), + cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse), + cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)), + cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)), + cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set), + cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add), + cmd!("select.toggle", "Toggle Selection", [], None, "{id}", has_doc, toggle), + cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key), + cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), + cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many), + cmd!("select.same.fillColor", "Fill Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.fillColor", + |a, b| a.appearance.fill_paint() == b.appearance.fill_paint() + )), + cmd!("select.same.strokeColor", "Stroke Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.strokeColor", + |a, b| a.appearance.stroke_paint() == b.appearance.stroke_paint() + )), + cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.strokeWeight", + |a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9 + )), + cmd!("select.same.fillAndStroke", "Fill & Stroke", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.fillAndStroke", + |a, b| a.appearance.fill_paint() == b.appearance.fill_paint() && a.appearance.stroke_paint() == b.appearance.stroke_paint() + )), + cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a + .opacity + - b.opacity) + .abs() + < 1e-6)), + cmd!("select.same.blendingMode", "Blending Mode", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.blendingMode", + |a, b| a.blend == b.blend + )), + cmd!("select.same.appearance", "Appearance", ["Select", "Same"], None, "{}", has_selection, |s, _| same( + s, + "select.same.appearance", + |a, b| a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend + )), + cmd!("select.same.shapeType", "Shape", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.shapeType", |a, b| a + .kind_label() + == b.kind_label())), + cmd!("select.object.allOnSameLayers", "All on Same Layers", ["Select", "Object"], None, "{}", has_selection, same_layers), + cmd!("select.object.clippingMasks", "Clipping Masks", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!( + n.kind, + NodeKind::Path { clipping: true, .. } + ))), + cmd!("select.object.textObjects", "All Text Objects", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!( + n.kind, + NodeKind::Text(_) + ))), + cmd!("select.object.strayPoints", "Stray Points", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n + .path_data() + .is_some_and(|p| p.anchor_count() == 1))), + cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n + .path_data() + .is_some_and(|p| !p.is_closed()))), + ] +} + +/// Selectable objects: children of visible, unlocked layers (or of the isolation container). +fn selectable(d: &Document, iso: Option) -> Vec { + match iso.and_then(|i| d.node(i)) { + Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(), + None => d + .layers + .iter() + .filter(|l| l.visible && !l.locked) + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| n.visible && !n.locked) + .map(|n| n.id) + .collect(), + } +} + +fn all(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let ids = selectable(&st.doc, st.isolation); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + Ok(json!({ "count": s.doc()?.selection.len() })) +} + +fn all_on_artboard(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?; + let ids: Vec = selectable(&st.doc, st.isolation) + .into_iter() + .filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center()))) + .collect(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + ok() +} + +fn none(s: &mut Session, _: &Value) -> Result { + s.select(|_, sel| sel.clear())?; + ok() +} + +fn reselect(s: &mut Session, _: &Value) -> Result { + let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() }; + s.execute(&c, &p) +} + +fn inverse(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let ids: Vec = selectable(&st.doc, st.isolation).into_iter().filter(|id| !st.selection.contains(*id)).collect(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + ok() +} + +fn step(s: &mut Session, dir: i64) -> Result { + let st = s.doc()?; + let Some(cur) = st.selection.objects.first().copied() else { return ok() }; + let Some((par, idx, n)) = st.doc.position(cur) else { return ok() }; + let j = idx as i64 + dir; + if j < 0 || j >= n as i64 { + return ok(); + } + let id = st.doc.children(par).unwrap()[j as usize].id; + s.select(|_, sel| sel.set([id]))?; + ok() +} + +fn set(s: &mut Session, p: &Value) -> Result { + let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?; + s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?; + ok() +} + +fn add(s: &mut Session, p: &Value) -> Result { + let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?; + s.select(|_, sel| { + for i in ids { + sel.add(i); + } + })?; + ok() +} + +fn toggle(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("select.toggle", "missing id"))?; + s.select(|_, sel| sel.toggle(id))?; + ok() +} + +fn key(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id"); + s.select(|_, sel| sel.key = id.filter(|i| sel.contains(*i)))?; + ok() +} + +fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { + v.and_then(Value::as_array) + .map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect()) + .unwrap_or_default() +} + +fn anchors(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?; + let refs = parse_refs(p.get("anchors")); + let mode = str_param(p, "mode").unwrap_or("set").to_string(); + s.select(|_, sel| { + if mode == "set" { + sel.clear(); + } + sel.add(id); + let e = sel.anchors.entry(id).or_default(); + for r in refs { + if mode == "toggle" && e.contains(&r) { + e.remove(&r); + } else { + e.insert(r); + } + } + })?; + ok() +} + +fn anchors_many(s: &mut Session, p: &Value) -> Result { + let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p + .get("items") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect()) + .unwrap_or_default(); + let add = bool_or(p, "add", false); + s.select(|d, sel| { + if !add { + sel.clear(); + } + for (id, refs) in items { + let total = d.node(id).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0); + sel.add(id); + if refs.len() < total { + sel.anchors.entry(id).or_default().extend(refs); + } else { + sel.anchors.remove(&id); + } + } + })?; + ok() +} + +fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { + let st = s.doc()?; + let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; + let mut ids = vec![]; + for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { + l.walk(&mut |n| { + if !n.is_container() && n.visible && !n.locked && eq(n, &refn) { + ids.push(n.id); + } + }); + } + let c = cmd.to_string(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + s.doc_mut()?.last_selection_cmd = Some((c, json!({}))); + Ok(json!({ "count": ids.len() })) +} + +fn same_layers(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let layers: BTreeSet = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect(); + let ids: Vec = layers + .iter() + .filter_map(|l| st.doc.node(*l)) + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| n.visible && !n.locked) + .map(|n| n.id) + .collect(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + ok() +} + +fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result { + let st = s.doc()?; + let mut ids = vec![]; + for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { + l.walk(&mut |n| { + if f(n) { + ids.push(n.id) + } + }); + } + s.select(|_, sel| sel.set(ids.iter().copied()))?; + Ok(json!({ "count": ids.len() })) +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs new file mode 100644 index 000000000..b7b689f3d --- /dev/null +++ b/crates/engine/src/inspect.rs @@ -0,0 +1,70 @@ +//! Agent-friendly document summaries and view-models for panels. + +use drawcraft_doc::{Node, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; + +fn rect_json(r: Option) -> Value { + match r { + Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }), + None => Value::Null, + } +} + +/// Compact tree summary of a node (for `document.inspect`). +pub fn node_summary(n: &Node) -> Value { + let mut v = json!({ + "id": n.id.0, + "name": n.display_name(), + "kind": n.kind_label(), + "visible": n.visible, + "locked": n.locked, + "bounds": rect_json(n.geometric_bounds()), + }); + if n.opacity < 1.0 { + v["opacity"] = json!(n.opacity); + } + if !n.is_container() { + v["fill"] = json!(n.appearance.fill_paint().label()); + v["stroke"] = json!(n.appearance.stroke_paint().label()); + v["strokeWidth"] = json!(n.appearance.stroke_width()); + } + match &n.kind { + NodeKind::Path { path, .. } => { + v["anchors"] = json!(path.anchor_count()); + v["closed"] = json!(path.is_closed()); + } + NodeKind::Text(t) => v["text"] = json!(t.plain_text()), + _ => {} + } + if let Some(ch) = n.children() { + v["children"] = Value::Array(ch.iter().rev().map(|c| node_summary(c)).collect()); + } + v +} + +pub fn document(s: &Session) -> Value { + let Some(st) = s.active() else { return Value::Null }; + let d = &st.doc; + json!({ + "title": st.title(), + "path": st.path, + "dirty": st.is_dirty(), + "revision": st.revision, + "units": d.units.label(), + "colorMode": format!("{:?}", d.color_mode), + "artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::>(), + // Top of the stack first, like the Layers panel. + "layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::>(), + "currentLayer": st.active_layer.map(|l| l.0), + "isolation": st.isolation.map(|l| l.0), + "selection": st.selection.objects.iter().map(|i| i.0).collect::>(), + "selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)), + "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), + "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), + "objects": d.node_count(), + "tool": s.tool_id(), + "paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active}, + }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 000000000..a690425d3 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,353 @@ +//! The DrawCraft engine façade. +//! +//! Every user-visible action is a command with a stable id (`object.group`, `select.same.fillColor`, +//! `shape.rectangle`…) and JSON parameters. The egui UI, the CLI, the control channel and the MCP +//! server all go through [`Session::execute`]. Tools (pointer gestures) are hosted here too and +//! reduce to commands, so every gesture is journaled and replayable. +#![forbid(unsafe_code)] + +pub mod cmd; +pub mod inspect; +mod tooling; + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Document, NodeId, Selection}; +use drawcraft_geom::Affine; +use drawcraft_tools::{PaintDefaults, Tool}; +use serde_json::Value; + +pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use drawcraft_doc as doc; +pub use drawcraft_tools as tools; +pub use tooling::{UiRequest, ViewInfo}; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active document")] + NoDocument, + #[error("no such object {0}")] + NoNode(NodeId), + #[error("{0}")] + Other(String), +} + +impl From for EngineError { + fn from(e: drawcraft_doc::DocError) -> Self { + EngineError::Other(e.to_string()) + } +} + +pub type Result = std::result::Result; + +/// One undo step. +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub label: String, + pub doc: Arc, + pub selection: Selection, +} + +#[derive(Clone, Debug, Default)] +pub struct History { + pub undo: Vec, + pub redo: Vec, + /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). + pub limit: usize, +} + +/// An in-progress drag: the snapshot the previews are applied to. +#[derive(Clone, Debug)] +pub struct Interaction { + pub label: String, + pub doc: Arc, + pub selection: Selection, + pub preview: Option<(String, Value)>, +} + +/// Per-document editing state. +#[derive(Clone, Debug)] +pub struct DocState { + pub doc: Arc, + pub selection: Selection, + pub history: History, + pub path: Option, + /// Increments on every change; UIs re-render when it moves. + pub revision: u64, + pub saved_revision: u64, + /// The layer new art goes into (the "current layer" in the Layers panel). + pub active_layer: Option, + /// Isolation mode container. + pub isolation: Option, + pub interaction: Option, + /// For Object → Transform → Transform Again (⌘D). + pub last_transform: Option<(Affine, bool)>, + /// Selection saved by Select → Reselect. + pub last_selection_cmd: Option<(String, Value)>, +} + +impl DocState { + pub fn new(doc: Document, path: Option) -> Self { + let active_layer = doc.default_layer(); + Self { + doc: Arc::new(doc), + selection: Selection::default(), + history: History { limit: 500, ..Default::default() }, + path, + revision: 1, + saved_revision: 1, + active_layer, + isolation: None, + interaction: None, + last_transform: None, + last_selection_cmd: None, + } + } + pub fn is_dirty(&self) -> bool { + self.revision != self.saved_revision + } + pub fn title(&self) -> String { + self.path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.doc.title.clone()) + } + /// Where new art is inserted: the isolation container, else the active layer. + pub fn insertion_parent(&self) -> Option { + if let Some(i) = self.isolation + && self.doc.node(i).is_some() + { + return Some(i); + } + self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| !n.locked)).or_else(|| self.doc.default_layer()) + } +} + +/// Application preferences that affect commands. +#[derive(Clone, Debug)] +pub struct Prefs { + /// Arrow-key nudge distance (General → Keyboard Increment), points. + pub keyboard_increment: f64, + /// Scale Strokes & Effects. + pub scale_strokes: bool, + /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). + pub paste_offset: f64, + pub corner_radius: f64, +} + +impl Default for Prefs { + fn default() -> Self { + Self { keyboard_increment: 1.0, scale_strokes: true, paste_offset: 10.0, corner_radius: 12.0 } + } +} + +pub struct Session { + docs: Vec, + active: Option, + pub prefs: Prefs, + /// Fill/stroke for new art (the toolbar proxy). + pub paint: PaintDefaults, + /// Which proxy is in front (true = Fill, false = Stroke) — the X key toggles. + pub fill_active: bool, + /// Internal clipboard (serialized nodes). + pub clipboard: Vec, + /// Executed commands (for actions and debugging). + pub journal: Vec<(String, Value)>, + pub(crate) tool: Box, + untitled_counter: u32, +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + pub fn new() -> Self { + Self { + docs: vec![], + active: None, + prefs: Prefs::default(), + paint: PaintDefaults { fill: Paint::solid(Color::WHITE), stroke: Paint::solid(Color::BLACK), stroke_width: 1.0 }, + fill_active: true, + clipboard: vec![], + journal: vec![], + tool: drawcraft_tools::create("selection"), + untitled_counter: 0, + } + } + + pub fn documents(&self) -> &[DocState] { + &self.docs + } + pub fn active_index(&self) -> Option { + self.active + } + pub fn active(&self) -> Option<&DocState> { + self.active.and_then(|i| self.docs.get(i)) + } + pub fn active_mut(&mut self) -> Option<&mut DocState> { + self.active.and_then(|i| self.docs.get_mut(i)) + } + pub fn doc(&self) -> Result<&DocState> { + self.active().ok_or(EngineError::NoDocument) + } + pub fn doc_mut(&mut self) -> Result<&mut DocState> { + self.active_mut().ok_or(EngineError::NoDocument) + } + pub fn set_active(&mut self, index: usize) -> bool { + if index < self.docs.len() { + self.active = Some(index); + true + } else { + false + } + } + pub fn next_untitled(&mut self) -> String { + self.untitled_counter += 1; + format!("Untitled-{}", self.untitled_counter) + } + /// Add a document and make it active. + pub fn add_document(&mut self, doc: Document, path: Option) -> usize { + self.docs.push(DocState::new(doc, path)); + let i = self.docs.len() - 1; + self.active = Some(i); + i + } + pub fn close_document(&mut self, index: usize) -> bool { + if index >= self.docs.len() { + return false; + } + self.docs.remove(index); + self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + true + } + + /// Execute a command by id. This is THE entry point for every frontend. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if let Err(why) = (spec.enabled)(self) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + let r = (spec.run)(self, params)?; + if spec.journal && self.active().is_none_or(|d| d.interaction.is_none()) { + self.journal.push((id.to_string(), params.clone())); + } + Ok(r) + } + + /// Run `f` on a mutable copy of the active document. Outside an interaction this records one + /// undo step labelled `label`; inside one it just mutates (the interaction commits later). + pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { + let st = self.doc_mut()?; + let before = st.doc.clone(); + let before_sel = st.selection.clone(); + let doc = Arc::make_mut(&mut st.doc); + match f(doc, &mut st.selection) { + Ok(v) => { + st.selection.prune(&st.doc); + st.revision += 1; + if st.interaction.is_none() { + st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel }); + if st.history.undo.len() > st.history.limit { + st.history.undo.remove(0); + } + st.history.redo.clear(); + } + Ok(v) + } + Err(e) => { + st.doc = before; + st.selection = before_sel; + Err(e) + } + } + } + + /// Change only the selection (not an undo step). + pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { + let st = self.doc_mut()?; + f(&st.doc, &mut st.selection); + st.selection.prune(&st.doc); + st.revision += 1; + Ok(()) + } + + // ---------- interactions (live drags) ---------- + + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + let st = self.doc_mut()?; + if st.interaction.is_some() { + return Ok(()); + } + st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None }); + Ok(()) + } + + /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). + pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { + { + let st = self.doc_mut()?; + let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; + st.doc = it.doc.clone(); + st.selection = it.selection.clone(); + } + let r = self.execute(cmd, params); + let st = self.doc_mut()?; + if let Some(it) = &mut st.interaction { + it.preview = Some((cmd.to_string(), params.clone())); + } + r + } + + pub fn commit_interaction(&mut self) -> Result<()> { + let st = self.doc_mut()?; + let Some(it) = st.interaction.take() else { return Ok(()) }; + let Some(preview) = it.preview else { return Ok(()) }; + if !Arc::ptr_eq(&it.doc, &st.doc) { + st.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection }); + st.history.redo.clear(); + st.revision += 1; + } + if preview.0 == "object.transform" { + let m = cmd::matrix_param(&preview.1, "matrix"); + let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); + if let Some(m) = m { + st.last_transform = Some((m, copy)); + } + } + self.journal.push(preview); + Ok(()) + } + + pub fn cancel_interaction(&mut self) -> Result<()> { + let st = self.doc_mut()?; + if let Some(it) = st.interaction.take() { + st.doc = it.doc; + st.selection = it.selection; + st.revision += 1; + } + Ok(()) + } + + pub fn in_interaction(&self) -> bool { + self.active().is_some_and(|d| d.interaction.is_some()) + } + + /// Commands with enablement (for menus, palette, MCP `list_commands`). + pub fn commands(&self) -> Vec { + command_specs().iter().map(|c| c.info(self)).collect() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 000000000..555014972 --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,307 @@ +use serde_json::json; + +use super::*; +use drawcraft_tools::{PointerEvent, PointerKind}; + +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()) +} + +#[test] +fn create_and_undo_redo() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + assert!(s.doc().unwrap().doc.node(a).is_some()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some()); + assert!(s.execute("edit.redo", &json!({})).is_err()); +} + +#[test] +fn unknown_and_disabled() { + let mut s = Session::new(); + assert!(matches!(s.execute("nope", &json!({})), Err(EngineError::UnknownCommand(_)))); + assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..)))); +} + +#[test] +fn group_ungroup() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(a), Some(g)); + assert_eq!(d.node(g).unwrap().children().unwrap().len(), 2); + s.execute("object.ungroup", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert_eq!(d.parent_of(a), d.layers.first().map(|l| l.id)); + assert_eq!(s.doc().unwrap().selection.len(), 2); +} + +#[test] +fn arrange_order() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let c = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + assert_eq!(order(&s), vec![b, c, a]); + s.execute("object.arrange.sendBackward", &json!({})).unwrap(); + assert_eq!(order(&s), vec![b, a, c]); + s.execute("object.arrange.sendToBack", &json!({})).unwrap(); + assert_eq!(order(&s), vec![a, b, c]); + s.execute("object.arrange.bringForward", &json!({})).unwrap(); + assert_eq!(order(&s), vec![b, a, c]); +} + +#[test] +fn transform_and_again() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap(); + assert_eq!(b.x0, 10.0); + s.execute("object.move", &json!({"dx": 0, "dy": 20, "copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); +} + +#[test] +fn rotate_and_scale() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap(); + assert!((b.width() - 10.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap(); + assert!((b.width() - 20.0).abs() < 1e-9); + // stroke scaled with the object + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width(), 2.0); +} + +#[test] +fn copy_paste_front_back() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let _b = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let r = s.execute("edit.pasteInFront", &json!({})).unwrap(); + let c = NodeId(r["ids"][0].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.index_path(c).unwrap()[1], 1); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 3); +} + +#[test] +fn fill_and_stroke_commands() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4, "cap": "round", "dash": [3, 2]})).unwrap(); + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + let st = n.appearance.stroke().unwrap(); + assert_eq!(st.width, 4.0); + assert_eq!(st.cap, drawcraft_doc::LineCap::Round); + assert_eq!(st.dash.as_ref().unwrap().pattern, vec![3.0, 2.0]); + s.execute("paint.swap", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); + s.execute("paint.setFill", &json!({"swatch": "Cyan"})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); +} + +#[test] +fn select_same_fill() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let _c = { + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + rect(&mut s, 40.0, 0.0, 10.0, 10.0) + }; + // Setting a fill also recolours the current selection (b), so b and c are blue, a green. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let r = s.execute("select.same.fillColor", &json!({})).unwrap(); + assert_eq!(r["count"], 2); + assert!(!s.doc().unwrap().selection.contains(a)); +} + +#[test] +fn layers_and_lock_hide() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let l2 = NodeId(s.execute("layer.new", &json!({"name": "Ink"})).unwrap()["id"].as_u64().unwrap()); + let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + assert_eq!(s.doc().unwrap().doc.layer_of(b), Some(l2)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("object.showAll", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).unwrap().visible); + s.execute("layer.delete", &json!({"id": l2.0})).unwrap(); + assert!(s.doc().unwrap().doc.node(b).is_none()); +} + +#[test] +fn clipping_and_compound() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 25.0, 25.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind_label(), "Compound Path"); + s.execute("object.compoundPath.release", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.len(), 2); + let g = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind_label(), "Clip Group"); +} + +#[test] +fn align_left() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0); +} + +#[test] +fn selection_tool_drag_is_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let undo_before = s.doc().unwrap().history.undo.len(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), v).unwrap(); + for x in [155.0, 160.0, 170.0, 180.0] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, 150.0), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap(); + let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap(); + assert_eq!(b.x0, 130.0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1); + assert_eq!(s.journal.last().unwrap().0, "object.transform"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0); +} + +#[test] +fn rectangle_tool_draws() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 40.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), v).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers[0].children().unwrap().len(), 1); + let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (100.0, 50.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), 1); +} + +#[test] +fn pen_tool_draws_closed_path() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("pen", v).unwrap(); + for (x, y) in [(10.0, 10.0), (100.0, 10.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(); + } + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), v).unwrap(); + let d = &s.doc().unwrap().doc; + let n = &d.layers[0].children().unwrap()[0]; + let p = n.path_data().unwrap(); + assert_eq!(p.anchor_count(), 3); + assert!(p.is_closed()); +} + +#[test] +fn direct_selection_moves_one_anchor() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.none", &json!({})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 90.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 90.0), v).unwrap(); + let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone(); + assert_eq!(p.subpaths[0].anchors[0].p, drawcraft_geom::Point::new(90.0, 90.0)); + assert_eq!(p.subpaths[0].anchors[1].p, drawcraft_geom::Point::new(200.0, 100.0)); +} + +#[test] +fn text_create_has_bounds() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello DrawCraft", "size": 24})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().clone(); + let b = n.geometric_bounds().unwrap(); + assert!(b.width() > 100.0, "{b:?}"); +} + +#[test] +fn inspect_lists_everything() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let v = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(v["layers"][0]["children"][0]["kind"], "Rectangle"); + assert!(s.commands().len() > 100); +} + +#[test] +fn every_command_has_unique_id_and_doc() { + let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect(); + let n = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), n); + assert!(command_specs().iter().all(|c| !c.params.is_empty() && !c.label.is_empty())); +} + +#[test] +fn every_command_survives_empty_params() { + // Robustness: no command may panic on {} (with and without a selection). + for sel in [false, true] { + for c in command_specs() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("edit.copy", &json!({})).ok(); + if sel { + s.execute("select.all", &json!({})).unwrap(); + } else { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + } + let _ = s.execute(c.id, &json!({})); + } + } +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs new file mode 100644 index 000000000..f4a834feb --- /dev/null +++ b/crates/engine/src/tooling.rs @@ -0,0 +1,119 @@ +//! Hosting the active tool: pointer/key events → actions → commands. + +use drawcraft_geom::Point; +use drawcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; +use serde_json::Value; + +use crate::{Result, Session}; + +/// View state the tools need from the frontend. +#[derive(Clone, Copy, Debug)] +pub struct ViewInfo { + pub zoom: f64, + pub outline: bool, + pub smart_guides: bool, + pub snap_to_grid: bool, + pub show_bbox: bool, +} + +impl Default for ViewInfo { + fn default() -> Self { + Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true } + } +} + +/// Requests from tools that only the frontend can fulfil. +#[derive(Clone, Debug, PartialEq)] +pub enum UiRequest { + Dialog(String, Value), + SwitchTool(String), +} + +impl Session { + pub fn tool_id(&self) -> &'static str { + self.tool.id() + } + + /// Switch tools (finishing any pending tool work first). + pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> { + if self.tool.id() == id { + return Ok(()); + } + let acts = self.with_cx(view, |t, cx| t.deactivate(cx)); + self.apply_actions(acts)?; + self.tool = drawcraft_tools::create(id); + Ok(()) + } + + fn with_cx(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R + where + R: Default, + { + let Some(st) = self.active.and_then(|i| self.docs.get(i)) else { return R::default() }; + let cx = ToolContext { + doc: &st.doc, + selection: &st.selection, + zoom: view.zoom, + isolation: st.isolation, + paint: &self.paint, + outline: view.outline, + smart_guides: view.smart_guides, + snap_to_grid: view.snap_to_grid, + show_bbox: view.show_bbox, + }; + f(self.tool.as_mut(), &cx) + } + + /// Feed a pointer event to the active tool. Returns requests for the UI. + pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { + let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev)); + self.apply_actions(acts) + } + + pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { + let acts = self.with_cx(view, |t, cx| t.key(cx, key, mods)); + self.apply_actions(acts) + } + + pub fn tool_busy(&self) -> bool { + self.tool.busy() + } + + pub fn overlays(&mut self, view: ViewInfo) -> Vec { + self.with_cx(view, |t, cx| t.overlays(cx)) + } + + pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { + self.with_cx(view, |t, cx| t.cursor(cx, p, mods)) + } + + pub fn tool_options(&self) -> Value { + self.tool.options() + } + pub fn set_tool_option(&mut self, key: &str, v: &Value) { + self.tool.set_option(key, v); + } + + /// Apply tool actions. Errors from previews are reported but keep the interaction alive. + pub fn apply_actions(&mut self, acts: Vec) -> Result> { + let mut ui = vec![]; + for a in acts { + match a { + Action::Begin(label) => self.begin_interaction(&label)?, + Action::Preview(cmd, p) => { + if let Err(e) = self.preview(&cmd, &p) { + log::warn!("preview {cmd}: {e}"); + } + } + Action::Commit => self.commit_interaction()?, + Action::Cancel => self.cancel_interaction()?, + Action::Exec(cmd, p) => { + self.execute(&cmd, &p)?; + } + Action::Dialog(k, p) => ui.push(UiRequest::Dialog(k, p)), + Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)), + } + } + Ok(ui) + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml new file mode 100644 index 000000000..b5d8268e6 --- /dev/null +++ b/crates/format/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "drawcraft-format" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft native .drawcraft format" + +[lints] +workspace = true + +[dependencies] +drawcraft-doc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs new file mode 100644 index 000000000..e9fdc3ca1 --- /dev/null +++ b/crates/format/src/lib.rs @@ -0,0 +1,2 @@ +//! DrawCraft native .drawcraft format +#![forbid(unsafe_code)] diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 000000000..271a63ce6 --- /dev/null +++ b/crates/geom/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "drawcraft-geom" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft geom" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/geom/src/hit.rs b/crates/geom/src/hit.rs new file mode 100644 index 000000000..944ded3e9 --- /dev/null +++ b/crates/geom/src/hit.rs @@ -0,0 +1,93 @@ +//! Hit testing primitives. + +use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape}; + +use crate::path::{FillRule, PathData}; + +/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled). +pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool { + let w = path.winding(p); + match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + } +} + +/// Distance from `p` to the nearest point on the path outline. +pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 { + path.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt() +} + +/// Is `p` within `tol` of the path's stroke of width `width`? +pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool { + let quick = path.bounding_box().inflate(width / 2.0 + tol, width / 2.0 + tol); + quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol +} + +/// Does the path intersect (or lie inside) the rect? Used for marquee selection. +pub fn intersects_rect(path: &PathData, r: Rect) -> bool { + let Some(b) = path.bounds() else { return false }; + if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) { + return false; + } + // Any anchor inside? + if path.anchors().any(|(_, _, a)| r.contains(a.p)) { + return true; + } + // Any segment crossing the rect edges? + let edges = [ + kurbo::Line::new((r.x0, r.y0), (r.x1, r.y0)), + kurbo::Line::new((r.x1, r.y0), (r.x1, r.y1)), + kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)), + kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)), + ]; + let bp = path.to_bezpath(); + for seg in bp.segments() { + for e in &edges { + if !seg.intersect_line(*e).is_empty() { + return true; + } + } + } + // Rect entirely inside a closed shape counts as touching it. + path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes; + + #[test] + fn fill_and_stroke() { + let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath(); + assert!(fill_contains(&p, FillRule::NonZero, Point::new(5.0, 5.0))); + assert!(!fill_contains(&p, FillRule::NonZero, Point::new(15.0, 5.0))); + assert!(stroke_contains(&p, 2.0, 0.0, Point::new(10.5, 5.0))); + assert!(!stroke_contains(&p, 2.0, 0.0, Point::new(12.0, 5.0))); + } + + #[test] + fn evenodd_hole() { + let mut outer = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + outer.subpaths.extend(shapes::rectangle(Rect::new(3.0, 3.0, 7.0, 7.0)).subpaths); + let bp = outer.to_bezpath(); + assert!(!fill_contains(&bp, FillRule::EvenOdd, Point::new(5.0, 5.0))); + assert!(fill_contains(&bp, FillRule::NonZero, Point::new(5.0, 5.0))); + assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0))); + } + + #[test] + fn marquee() { + let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(intersects_rect(&p, Rect::new(-1.0, 4.0, 1.0, 6.0))); + assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0))); + // corner region of the bbox that misses the ellipse outline + assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5))); + // fully inside + assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0))); + // crossing a line + let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0)); + assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0))); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 000000000..f6cb6db5e --- /dev/null +++ b/crates/geom/src/lib.rs @@ -0,0 +1,101 @@ +//! DrawCraft geometry kernel. +//! +//! The editable path model is anchor based (like every Illustrator tool thinks about paths): +//! a [`PathData`] is a list of [`SubPath`]s, each a list of [`Anchor`]s with absolute in/out +//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`]. +//! +//! Coordinates are document points (1/72 in), y pointing down. +#![forbid(unsafe_code)] + +pub mod hit; +pub mod path; +pub mod shapes; +pub mod snap; + +pub use kurbo; +pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; +pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath}; + +/// Tolerance used when comparing handle positions to anchor positions. +pub const EPS: f64 = 1e-9; + +/// Union of two optional rects. +pub fn union_opt(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, None) => a, + (None, b) => b, + } +} + +/// Normalize an angle in degrees to (-180, 180]. +pub fn normalize_deg(mut a: f64) -> f64 { + a %= 360.0; + if a <= -180.0 { + a += 360.0; + } else if a > 180.0 { + a -= 360.0; + } + a +} + +/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags). +pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 { + let len = v.hypot(); + if len < EPS { + return v; + } + let step = step_deg.to_radians(); + let a = (v.y.atan2(v.x) / step).round() * step; + Vec2::new(a.cos() * len, a.sin() * len) +} + +/// Rect from two corner points in any order. +pub fn rect_from_points(a: Point, b: Point) -> Rect { + Rect::from_points(a, b) +} + +/// The 9 reference points of a rect (Illustrator's reference-point locator), row-major from top-left. +pub fn reference_point(r: Rect, index: usize) -> Point { + let xs = [r.x0, r.center().x, r.x1]; + let ys = [r.y0, r.center().y, r.y1]; + Point::new(xs[index % 3], ys[(index / 3).min(2)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn angle_normalization() { + assert_eq!(normalize_deg(190.0), -170.0); + assert_eq!(normalize_deg(-190.0), 170.0); + assert_eq!(normalize_deg(180.0), 180.0); + assert_eq!(normalize_deg(720.0), 0.0); + } + + #[test] + fn constrain_45() { + let v = constrain_angle(Vec2::new(10.0, 1.0), 45.0); + assert!((v.y).abs() < 1e-9); + let v = constrain_angle(Vec2::new(10.0, 9.0), 45.0); + assert!((v.x - v.y).abs() < 1e-9); + } + + #[test] + fn reference_points() { + let r = Rect::new(0.0, 0.0, 10.0, 20.0); + assert_eq!(reference_point(r, 0), Point::new(0.0, 0.0)); + assert_eq!(reference_point(r, 4), Point::new(5.0, 10.0)); + assert_eq!(reference_point(r, 8), Point::new(10.0, 20.0)); + } + + #[test] + fn union_options() { + let a = Rect::new(0.0, 0.0, 1.0, 1.0); + let b = Rect::new(2.0, 2.0, 3.0, 3.0); + assert_eq!(union_opt(Some(a), Some(b)), Some(Rect::new(0.0, 0.0, 3.0, 3.0))); + assert_eq!(union_opt(None, Some(b)), Some(b)); + assert_eq!(union_opt(None, None), None); + } +} diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs new file mode 100644 index 000000000..2a23f9b5f --- /dev/null +++ b/crates/geom/src/path.rs @@ -0,0 +1,440 @@ +//! The anchor-based editable path model. + +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape}; +use serde::{Deserialize, Serialize}; + +use crate::EPS; + +/// Fill rule for paths and compound paths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FillRule { + /// Illustrator's default for compound paths created from overlapping shapes. + #[default] + NonZero, + EvenOdd, +} + +/// How an anchor's handles behave when edited. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnchorKind { + /// Handles move independently (or there are none). + #[default] + Corner, + /// Handles stay collinear (dragging one rotates the other). + Smooth, +} + +/// One anchor point with absolute handle positions. A handle equal to `p` means "no handle". +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Anchor { + pub p: Point, + #[serde(rename = "in")] + pub h_in: Point, + #[serde(rename = "out")] + pub h_out: Point, + #[serde(default)] + pub kind: AnchorKind, +} + +impl Anchor { + /// A corner anchor without handles. + pub fn corner(p: Point) -> Self { + Self { p, h_in: p, h_out: p, kind: AnchorKind::Corner } + } + /// A smooth anchor with a symmetric out handle `out` (the in handle is mirrored). + pub fn smooth(p: Point, out: Point) -> Self { + Self { p, h_in: p - (out - p), h_out: out, kind: AnchorKind::Smooth } + } + pub fn with_handles(p: Point, h_in: Point, h_out: Point) -> Self { + let kind = + if is_collinear(p, h_in, h_out) && h_in.distance(p) > EPS && h_out.distance(p) > EPS { AnchorKind::Smooth } else { AnchorKind::Corner }; + Self { p, h_in, h_out, kind } + } + pub fn has_in(&self) -> bool { + self.h_in.distance(self.p) > EPS + } + pub fn has_out(&self) -> bool { + self.h_out.distance(self.p) > EPS + } + pub fn transform(&self, a: Affine) -> Self { + Self { p: a * self.p, h_in: a * self.h_in, h_out: a * self.h_out, kind: self.kind } + } + pub fn translate(&mut self, d: kurbo::Vec2) { + self.p += d; + self.h_in += d; + self.h_out += d; + } + /// Swap handles (used when reversing path direction). + pub fn reversed(&self) -> Self { + Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind } + } + /// Remove both handles (convert to a sharp corner). + pub fn retract(&mut self) { + self.h_in = self.p; + self.h_out = self.p; + self.kind = AnchorKind::Corner; + } +} + +fn is_collinear(p: Point, a: Point, b: Point) -> bool { + let va = a - p; + let vb = b - p; + let cross = va.cross(vb); + cross.abs() <= 1e-6 * va.hypot().max(1.0) * vb.hypot().max(1.0) && va.dot(vb) < 0.0 +} + +/// An open or closed run of anchors. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SubPath { + pub anchors: Vec, + #[serde(default)] + pub closed: bool, +} + +impl SubPath { + pub fn new(anchors: Vec, closed: bool) -> Self { + Self { anchors, closed } + } + /// Polyline from points. + pub fn polyline(points: &[Point], closed: bool) -> Self { + Self { anchors: points.iter().map(|&p| Anchor::corner(p)).collect(), closed } + } + /// Number of segments (a closed path has one more, back to the start). + pub fn segment_count(&self) -> usize { + let n = self.anchors.len(); + if n < 2 { + 0 + } else if self.closed { + n + } else { + n - 1 + } + } + /// Segment `i` as a cubic (lines are cubics with handles on the anchors). + pub fn segment(&self, i: usize) -> CubicBez { + let n = self.anchors.len(); + let a = &self.anchors[i % n]; + let b = &self.anchors[(i + 1) % n]; + CubicBez::new(a.p, a.h_out, b.h_in, b.p) + } + /// True if segment `i` is a straight line. + pub fn segment_is_line(&self, i: usize) -> bool { + let n = self.anchors.len(); + let a = &self.anchors[i % n]; + let b = &self.anchors[(i + 1) % n]; + !a.has_out() && !b.has_in() + } + pub fn to_bezpath_into(&self, out: &mut BezPath) { + let Some(first) = self.anchors.first() else { return }; + out.move_to(first.p); + for i in 0..self.segment_count() { + let c = self.segment(i); + if self.segment_is_line(i) { + out.line_to(c.p3); + } else { + out.curve_to(c.p1, c.p2, c.p3); + } + } + if self.closed { + out.close_path(); + } + } + pub fn reverse(&mut self) { + self.anchors.reverse(); + for a in &mut self.anchors { + *a = a.reversed(); + } + } + /// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index. + pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize { + let n = self.anchors.len(); + let c = self.segment(seg); + let line = self.segment_is_line(seg); + let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0)); + let i0 = seg % n; + let i1 = (seg + 1) % n; + let mid = if line { + Anchor::corner(l.p3) + } else { + self.anchors[i0].h_out = l.p1; + self.anchors[i1].h_in = r.p2; + Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth } + }; + self.anchors.insert(seg + 1, mid); + seg + 1 + } + /// Signed area via the shoelace formula on the Bézier path (positive = clockwise in y-down). + pub fn area(&self) -> f64 { + let mut bp = BezPath::new(); + self.to_bezpath_into(&mut bp); + bp.area() + } +} + +/// A (possibly multi-subpath) path. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PathData { + pub subpaths: Vec, +} + +impl PathData { + pub fn new(subpaths: Vec) -> Self { + Self { subpaths } + } + pub fn single(sp: SubPath) -> Self { + Self { subpaths: vec![sp] } + } + pub fn is_empty(&self) -> bool { + self.subpaths.iter().all(|s| s.anchors.is_empty()) + } + pub fn anchor_count(&self) -> usize { + self.subpaths.iter().map(|s| s.anchors.len()).sum() + } + pub fn to_bezpath(&self) -> BezPath { + let mut bp = BezPath::new(); + for sp in &self.subpaths { + sp.to_bezpath_into(&mut bp); + } + bp + } + /// Convert a kurbo path to anchors. Quadratics are elevated to cubics. + pub fn from_bezpath(bp: &BezPath) -> Self { + let mut subpaths: Vec = Vec::new(); + let mut cur: Option = None; + let mut last = Point::ZERO; + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) => { + if let Some(sp) = cur.take() + && !sp.anchors.is_empty() + { + subpaths.push(sp); + } + cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false }); + last = p; + } + PathEl::LineTo(p) => { + let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + sp.anchors.push(Anchor::corner(p)); + last = p; + } + PathEl::QuadTo(q, p) => { + let c1 = last + (q - last) * (2.0 / 3.0); + let c2 = p + (q - p) * (2.0 / 3.0); + push_curve(&mut cur, last, c1, c2, p); + last = p; + } + PathEl::CurveTo(c1, c2, p) => { + push_curve(&mut cur, last, c1, c2, p); + last = p; + } + PathEl::ClosePath => { + if let Some(mut sp) = cur.take() { + // Merge a closing anchor that duplicates the start. + if sp.anchors.len() > 1 { + let first = sp.anchors[0].p; + let lastp = sp.anchors.last().unwrap().p; + if first.distance(lastp) < 1e-7 { + let l = sp.anchors.pop().unwrap(); + sp.anchors[0].h_in = l.h_in; + } + } + sp.closed = true; + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + last = sp.anchors.first().map(|a| a.p).unwrap_or(last); + subpaths.push(sp); + } + } + } + } + if let Some(mut sp) = cur + && !sp.anchors.is_empty() + { + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + subpaths.push(sp); + } + Self { subpaths } + } + /// Tight geometric bounds (curve extrema, not control points). + pub fn bounds(&self) -> Option { + if self.is_empty() { + return None; + } + let bp = self.to_bezpath(); + if self.anchor_count() == 1 { + let p = self.subpaths.iter().find_map(|s| s.anchors.first()).unwrap().p; + return Some(Rect::from_points(p, p)); + } + Some(bp.bounding_box()) + } + /// Bounds including control handles (used for quick culling). + pub fn control_bounds(&self) -> Option { + let mut it = self.subpaths.iter().flat_map(|s| s.anchors.iter()).flat_map(|a| [a.p, a.h_in, a.h_out]); + let first = it.next()?; + Some(it.fold(Rect::from_points(first, first), |r, p| r.union_pt(p))) + } + pub fn transform(&mut self, a: Affine) { + for sp in &mut self.subpaths { + for an in &mut sp.anchors { + *an = an.transform(a); + } + } + } + pub fn transformed(&self, a: Affine) -> Self { + let mut c = self.clone(); + c.transform(a); + c + } + pub fn is_closed(&self) -> bool { + !self.subpaths.is_empty() && self.subpaths.iter().all(|s| s.closed) + } + /// Nearest point on any segment: (subpath, segment, t, point, distance). + pub fn nearest(&self, p: Point) -> Option<(usize, usize, f64, Point, f64)> { + let mut best: Option<(usize, usize, f64, Point, f64)> = None; + for (si, sp) in self.subpaths.iter().enumerate() { + for seg in 0..sp.segment_count() { + let c = sp.segment(seg); + let n = c.nearest(p, 1e-6); + let q = c.eval(n.t); + let d = n.distance_sq.sqrt(); + if best.is_none_or(|b| d < b.4) { + best = Some((si, seg, n.t, q, d)); + } + } + } + best + } + /// Iterate (subpath index, anchor index, anchor). + pub fn anchors(&self) -> impl Iterator { + self.subpaths.iter().enumerate().flat_map(|(si, s)| s.anchors.iter().enumerate().map(move |(ai, a)| (si, ai, a))) + } + pub fn anchor_mut(&mut self, si: usize, ai: usize) -> Option<&mut Anchor> { + self.subpaths.get_mut(si)?.anchors.get_mut(ai) + } + pub fn reverse(&mut self) { + for sp in &mut self.subpaths { + sp.reverse(); + } + } + /// Total length of all segments. + pub fn length(&self) -> f64 { + self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum() + } +} + +fn push_curve(cur: &mut Option, last: Point, c1: Point, c2: Point, p: Point) { + let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + if let Some(a) = sp.anchors.last_mut() { + a.h_out = c1; + } + sp.anchors.push(Anchor { p, h_in: c2, h_out: p, kind: AnchorKind::Corner }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square() -> PathData { + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) + } + + #[test] + fn square_bounds_and_area() { + let p = square(); + assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); + assert!((p.subpaths[0].area().abs() - 100.0).abs() < 1e-9); + assert_eq!(p.subpaths[0].segment_count(), 4); + } + + #[test] + fn bezpath_roundtrip_polyline() { + let p = square(); + let bp = p.to_bezpath(); + let back = PathData::from_bezpath(&bp); + assert_eq!(back.subpaths.len(), 1); + assert_eq!(back.subpaths[0].anchors.len(), 4); + assert!(back.subpaths[0].closed); + assert_eq!(back.bounds(), p.bounds()); + } + + #[test] + fn bezpath_roundtrip_curves() { + let c = kurbo::Circle::new((50.0, 50.0), 20.0); + let bp = c.to_path(1e-3); + let pd = PathData::from_bezpath(&bp); + assert_eq!(pd.subpaths.len(), 1); + assert!(pd.subpaths[0].closed); + let b = pd.bounds().unwrap(); + assert!((b.width() - 40.0).abs() < 1e-3); + // All anchors of a circle are smooth. + assert!(pd.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth)); + } + + #[test] + fn insert_anchor_on_line_keeps_shape() { + let mut p = square(); + let before = p.bounds(); + let idx = p.subpaths[0].insert_anchor(0, 0.5); + assert_eq!(idx, 1); + assert_eq!(p.subpaths[0].anchors[1].p, Point::new(5.0, 0.0)); + assert_eq!(p.bounds(), before); + assert_eq!(p.anchor_count(), 5); + } + + #[test] + fn insert_anchor_on_curve_keeps_shape() { + let bp = kurbo::Circle::new((0.0, 0.0), 10.0).to_path(1e-3); + let mut pd = PathData::from_bezpath(&bp); + let area0 = pd.subpaths[0].area(); + pd.subpaths[0].insert_anchor(1, 0.3); + assert!((pd.subpaths[0].area() - area0).abs() < 1e-6); + } + + #[test] + fn nearest_on_edge() { + let p = square(); + let (_, seg, _, q, d) = p.nearest(Point::new(5.0, -3.0)).unwrap(); + assert_eq!(seg, 0); + assert!((q.x - 5.0).abs() < 1e-6 && q.y.abs() < 1e-6); + assert!((d - 3.0).abs() < 1e-6); + } + + #[test] + fn reverse_flips_area_sign() { + let mut p = square(); + let a = p.subpaths[0].area(); + p.reverse(); + assert!((p.subpaths[0].area() + a).abs() < 1e-9); + } + + #[test] + fn transform_moves_bounds() { + let p = square().transformed(Affine::translate((5.0, 5.0))); + assert_eq!(p.bounds(), Some(Rect::new(5.0, 5.0, 15.0, 15.0))); + } + + #[test] + fn smooth_detection() { + let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(2.0, 0.0)); + assert_eq!(a.kind, AnchorKind::Smooth); + let c = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(0.0, 2.0)); + assert_eq!(c.kind, AnchorKind::Corner); + } + + #[test] + fn length_of_square() { + assert!((square().length() - 40.0).abs() < 1e-6); + } + + #[test] + fn serde_roundtrip() { + let p = square(); + let s = serde_json::to_string(&p).unwrap(); + let back: PathData = serde_json::from_str(&s).unwrap(); + assert_eq!(p, back); + } +} diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs new file mode 100644 index 000000000..c2144a348 --- /dev/null +++ b/crates/geom/src/shapes.rs @@ -0,0 +1,246 @@ +//! Shape generators matching Illustrator's anchor layouts (so later edits behave the same). + +use std::f64::consts::{PI, TAU}; + +use kurbo::{Point, Rect, Vec2}; + +use crate::path::{Anchor, AnchorKind, PathData, SubPath}; + +/// Circle-approximation handle ratio for a quarter arc. +pub const KAPPA: f64 = 0.552_284_749_830_793_6; + +/// Rectangle with 4 corner anchors, clockwise from the top-left. +pub fn rectangle(r: Rect) -> PathData { + let r = r.abs(); + PathData::single(SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)) +} + +/// Rounded rectangle (8 anchors). The radius is clamped to half the shorter side. +pub fn rounded_rectangle(r: Rect, radius: f64) -> PathData { + let r = r.abs(); + let rad = radius.max(0.0).min(r.width() / 2.0).min(r.height() / 2.0); + if rad <= 1e-9 { + return rectangle(r); + } + let k = rad * KAPPA; + let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1); + let a = |p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner }; + let anchors = vec![ + a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)), + a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)), + a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)), + a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)), + a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)), + a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)), + a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)), + a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)), + ]; + PathData::single(SubPath::new(anchors, true)) +} + +/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom). +pub fn ellipse(r: Rect) -> PathData { + let r = r.abs(); + let c = r.center(); + let rx = r.width() / 2.0; + let ry = r.height() / 2.0; + let kx = rx * KAPPA; + let ky = ry * KAPPA; + let s = |p: Point, hin: Vec2| Anchor { p, h_in: p + hin, h_out: p - hin, kind: AnchorKind::Smooth }; + let anchors = vec![ + s(Point::new(c.x - rx, c.y), Vec2::new(0.0, ky)), + s(Point::new(c.x, c.y - ry), Vec2::new(-kx, 0.0)), + s(Point::new(c.x + rx, c.y), Vec2::new(0.0, -ky)), + s(Point::new(c.x, c.y + ry), Vec2::new(kx, 0.0)), + ]; + PathData::single(SubPath::new(anchors, true)) +} + +/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre. +pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData { + let n = sides.max(3); + let rot = rotation_deg.to_radians(); + let pts: Vec = (0..n) + .map(|i| { + let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64; + Point::new(center.x + radius * a.cos(), center.y + radius * a.sin()) + }) + .collect(); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Star with `points` tips (≥ 2), outer radius `r1`, inner radius `r2`, first tip up. +pub fn star(center: Point, r1: f64, r2: f64, points: u32, rotation_deg: f64) -> PathData { + let n = points.max(2) * 2; + let rot = rotation_deg.to_radians(); + let pts: Vec = (0..n) + .map(|i| { + let r = if i % 2 == 0 { r1 } else { r2 }; + let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64; + Point::new(center.x + r * a.cos(), center.y + r * a.sin()) + }) + .collect(); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Inner radius of a "straight-armed" star (Illustrator's Alt-drag behaviour): shoulders aligned. +pub fn straight_star_inner_radius(r1: f64, points: u32) -> f64 { + let n = points.max(3) as f64; + r1 * (PI / n * 2.0).cos() / (PI / n).cos() +} + +/// Open straight line. +pub fn line(a: Point, b: Point) -> PathData { + PathData::single(SubPath::polyline(&[a, b], false)) +} + +/// Quarter-ellipse arc from `a` to `b` (Arc tool default: concave, open, x-axis base). +pub fn arc(a: Point, b: Point, slope: f64, closed: bool) -> PathData { + // Corner of the arc's bounding box is at (a.x, b.y): the arc bows away from it. + let k = KAPPA * (1.0 + slope.clamp(-1.0, 1.0) * 0.0); + let corner = Point::new(b.x, a.y); + let h1 = a + (corner - a) * k; + let h2 = b + (corner - b) * k; + let mut anchors = + vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }]; + if closed { + let o = Point::new(a.x, b.y); + anchors.push(Anchor::corner(o)); + } + PathData::single(SubPath::new(anchors, closed)) +} + +/// Archimedean-style spiral approximating Illustrator's Spiral tool (decay-based, `segments` quarter turns). +pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData { + let decay = (decay_percent / 100.0).clamp(0.05, 0.9999); + let dir = if clockwise { 1.0 } else { -1.0 }; + let mut anchors = Vec::new(); + let mut r = radius; + for i in 0..=segments { + let a = dir * (i as f64) * PI / 2.0; + let p = Point::new(center.x + r * a.cos(), center.y + r * a.sin()); + // Tangent direction for a quarter arc of radius r. + let t = Vec2::new(-a.sin(), a.cos()) * dir; + let r_prev = r / decay; + let r_next = r * decay; + let h_in = p - t * (KAPPA * (r + r_prev) / 2.0); + let h_out = p + t * (KAPPA * (r + r_next) / 2.0); + anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth }); + r *= decay; + } + // Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end. + let mut sp = SubPath::new(anchors, false); + sp.reverse(); + PathData::single(sp) +} + +/// Rectangular grid as separate open lines (plus an optional frame rectangle). +pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec { + let r = r.abs(); + let mut out = Vec::new(); + for i in 1..=h_dividers { + let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64; + out.push(line(Point::new(r.x0, y), Point::new(r.x1, y))); + } + for i in 1..=v_dividers { + let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64; + out.push(line(Point::new(x, r.y0), Point::new(x, r.y1))); + } + if frame { + out.push(rectangle(r)); + } + out +} + +/// Polar grid: concentric ellipses plus radial dividers. +pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec { + let r = r.abs(); + let c = r.center(); + let mut out = Vec::new(); + for i in 1..=concentric + 1 { + let f = i as f64 / (concentric + 1) as f64; + out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)))); + } + for i in 0..radial.max(1) { + let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64; + out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin()))); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_is_4_corners_clockwise() { + let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)); + assert_eq!(p.anchor_count(), 4); + assert!(p.subpaths[0].area() > 0.0); + assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 100.0, 50.0))); + } + + #[test] + fn rect_normalizes_negative() { + let p = rectangle(Rect::new(10.0, 10.0, 0.0, 0.0)); + assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); + } + + #[test] + fn ellipse_bounds_and_area() { + let p = ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + let b = p.bounds().unwrap(); + assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6); + let area = p.subpaths[0].area().abs(); + assert!((area - PI * 50.0 * 30.0).abs() / area < 0.001); + } + + #[test] + fn rounded_rect_clamps() { + let p = rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 100.0); + assert_eq!(p.anchor_count(), 8); + assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 20.0, 10.0))); + assert_eq!(rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0).anchor_count(), 4); + } + + #[test] + fn polygon_first_vertex_up() { + let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0); + assert_eq!(p.anchor_count(), 6); + let a = p.subpaths[0].anchors[0].p; + assert!(a.x.abs() < 1e-9 && (a.y + 10.0).abs() < 1e-9); + } + + #[test] + fn star_alternates_radii() { + let p = star(Point::ZERO, 10.0, 5.0, 5, 0.0); + assert_eq!(p.anchor_count(), 10); + let d: Vec = p.subpaths[0].anchors.iter().map(|a| a.p.to_vec2().hypot()).collect(); + assert!((d[0] - 10.0).abs() < 1e-9 && (d[1] - 5.0).abs() < 1e-9); + } + + #[test] + fn straight_star_five_points() { + let r2 = straight_star_inner_radius(100.0, 5); + assert!((r2 - 38.196_601).abs() < 1e-3); + } + + #[test] + fn spiral_has_segments() { + let p = spiral(Point::ZERO, 100.0, 80.0, 10, true); + assert_eq!(p.anchor_count(), 11); + assert!(!p.subpaths[0].closed); + } + + #[test] + fn grids() { + assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11); + assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11); + } + + #[test] + fn arc_open_and_closed() { + assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false).anchor_count(), 2); + assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true).subpaths[0].closed); + } +} diff --git a/crates/geom/src/snap.rs b/crates/geom/src/snap.rs new file mode 100644 index 000000000..caa3f569e --- /dev/null +++ b/crates/geom/src/snap.rs @@ -0,0 +1,46 @@ +//! Snapping math shared by tools and smart guides. + +use kurbo::Point; + +/// Snap a value to a grid with the given spacing and origin. +pub fn snap_to_grid(v: f64, spacing: f64, origin: f64) -> f64 { + if spacing <= 0.0 { + return v; + } + ((v - origin) / spacing).round() * spacing + origin +} + +pub fn snap_point_to_grid(p: Point, spacing: f64) -> Point { + Point::new(snap_to_grid(p.x, spacing, 0.0), snap_to_grid(p.y, spacing, 0.0)) +} + +/// A candidate alignment line for smart guides. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Axis { + X, + Y, +} + +/// Find the closest target within `tol` to `v`. Returns (target, delta). +pub fn closest(v: f64, targets: impl IntoIterator, tol: f64) -> Option<(f64, f64)> { + targets.into_iter().map(|t| (t, t - v)).filter(|(_, d)| d.abs() <= tol).min_by(|a, b| a.1.abs().total_cmp(&b.1.abs())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid() { + assert_eq!(snap_to_grid(13.0, 10.0, 0.0), 10.0); + assert_eq!(snap_to_grid(16.0, 10.0, 0.0), 20.0); + assert_eq!(snap_to_grid(16.0, 10.0, 3.0), 13.0); + assert_eq!(snap_to_grid(16.0, 0.0, 0.0), 16.0); + } + + #[test] + fn closest_target() { + assert_eq!(closest(10.0, [0.0, 11.0, 9.5], 2.0), Some((9.5, -0.5))); + assert_eq!(closest(10.0, [0.0, 20.0], 2.0), None); + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 000000000..0050027e1 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "drawcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 000000000..1209a8580 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1 @@ +//! stub diff --git a/crates/pathops/Cargo.toml b/crates/pathops/Cargo.toml new file mode 100644 index 000000000..97dc8da10 --- /dev/null +++ b/crates/pathops/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "drawcraft-pathops" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft path operations: booleans (Pathfinder), shape builder regions, offset, outline stroke, simplify" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +kurbo = { workspace = true } +thiserror = { workspace = true } +linesweeper = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/pathops/README.md b/crates/pathops/README.md new file mode 100644 index 000000000..267da6642 --- /dev/null +++ b/crates/pathops/README.md @@ -0,0 +1,81 @@ +# drawcraft-pathops + +Path operations for DrawCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `drawcraft_geom::PathData`. + +## Booleans (curve-preserving) + +```rust +pub enum BoolOp { Union, Intersect, Difference, Xor } +pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp) -> PathData; +pub fn try_boolean(.., precision: f64) -> Result; // errors on NaN/inf +pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData; +pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData; // fast many-shape union +pub fn normalize(path: &PathData, rule: FillRule) -> PathData; // resolve self-overlaps +pub fn area(path: &PathData, rule: FillRule) -> f64; // exact filled area +``` + +The engine is `linesweeper` (a robust sweep line that works directly on cubic Béziers, so there's no polygon flattening). The sweep splits curves at intersections and y-extrema. Afterwards: + +- pieces that lie on one input cubic are rebuilt exactly as a single sub-curve of that cubic; +- other smooth joints (for example, in stroker output) are refitted by least squares within `DEFAULT_PRECISION` (0.01 pt); +- collinear line pieces are merged; +- the inputs' own anchors are always kept; +- slivers narrower than the precision are dropped. + +Output contours are simple and consistently oriented: outer contours and holes wind in opposite directions, so the result fills the same under either fill rule. Open subpaths are closed implicitly because they're treated as filled. + +## Pathfinder / Shape Builder + +```rust +pub struct Shape { pub path: PathData, pub rule: FillRule, pub key: u64 } // key = paint identity +pub enum PathfinderOp { Unite, MinusFront, Intersect, Exclude, Divide, Trim, Merge, Crop, Outline, MinusBack } +pub fn pathfinder(op: PathfinderOp, shapes: &[Shape] /* back → front */) -> Vec; + +pub struct Region { pub path: PathData, pub sources: Vec } +pub fn regions(shapes: &[Shape]) -> Vec; // all faces of the arrangement +pub fn region_at(shapes: &[Shape], p: Point) -> Option; +pub fn merge_regions(regions: &[&Region]) -> PathData; // Shape Builder merge = union +``` + +Each result takes its key as follows: + +| Operation | Result | +|---|---| +| Unite, Intersect, Exclude, Minus Back | Takes the front-most key | +| Minus Front | Takes the back-most key | +| Divide | One shape per face, keyed by the front-most shape that covers the face | +| Trim | One shape for each object that is still visible | +| Merge | Joins touching visible parts that share a key; each connected component becomes one shape | +| Crop | Keeps the visible parts of the lower objects inside the front-most object | +| Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key | + +## Offset / stroke + +```rust +pub use kurbo::{Cap, Join}; +pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData; // delta < 0 insets +pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData; +``` + +`outline_stroke` passes the path to `kurbo::stroke` and then normalizes the result, which removes the overlaps at joins. `offset_path` computes `fill ∪ stroke(2|d|)` for a positive offset and `fill − stroke(2|d|)` for a negative one. + +## Path editing + +```rust +pub fn simplify(path: &PathData, tolerance: f64) -> PathData; +pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData; // tolerance, corner_angle_deg, straight_lines +pub fn smooth(path: &PathData, amount: f64) -> PathData; +pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData; +pub fn add_anchor_points(path: &PathData) -> PathData; +pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData; +pub fn join(paths: &[PathData], tolerance: f64) -> PathData; +pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec; +``` + +`simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner. + +## Caveats + +- linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it. +- The Outline op finds split points by snapping to arrangement junctions with a small tolerance. +- The quality of offsets depends on kurbo's parallel-curve stroker. Its output is refitted, so offset results have more anchors than boolean results. diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs new file mode 100644 index 000000000..5abf31e0b --- /dev/null +++ b/crates/pathops/src/boolean.rs @@ -0,0 +1,513 @@ +//! Curve-preserving boolean operations built on `linesweeper`'s robust sweep-line topology. +//! +//! Inputs are converted to closed [`BezPath`]s (open subpaths are implicitly closed, as a filled +//! open path is painted in Illustrator). The sweep splits curves at intersections and y-extrema; +//! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so +//! results have roughly as few anchors as the inputs. + +use std::collections::BTreeSet; + +use drawcraft_geom::{Anchor, FillRule, PathData, SubPath}; +use kurbo::{BezPath, CubicBez, PathSeg, Point, Shape as _}; +use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; + +use crate::PathOpsError; +use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent}; + +/// Default curve-refit tolerance (document points) used to merge split curve pieces back together. +/// Similar in spirit to Illustrator's Pathfinder "Precision" option (0.028 pt). +pub const DEFAULT_PRECISION: f64 = 0.01; + +/// Binary boolean operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BoolOp { + /// A ∪ B + Union, + /// A ∩ B + Intersect, + /// A − B + Difference, + /// A ⊕ B (symmetric difference) + Xor, +} + +impl BoolOp { + /// Evaluate the operation on "inside A" / "inside B" flags. + pub fn apply(self, a: bool, b: bool) -> bool { + match self { + BoolOp::Union => a || b, + BoolOp::Intersect => a && b, + BoolOp::Difference => a && !b, + BoolOp::Xor => a != b, + } + } +} + +pub(crate) fn inside(rule: FillRule, w: i32) -> bool { + match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + } +} + +/// Winding numbers for an arbitrary number of input sets (tag = input index). +#[derive(Clone, Debug, Default)] +pub(crate) struct Multi(pub(crate) Vec); + +impl Multi { + fn get(&self, i: usize) -> i32 { + self.0.get(i).copied().unwrap_or(0) + } +} + +impl PartialEq for Multi { + fn eq(&self, other: &Self) -> bool { + let n = self.0.len().max(other.0.len()); + (0..n).all(|i| self.get(i) == other.get(i)) + } +} +impl Eq for Multi {} + +impl std::ops::Add for Multi { + type Output = Multi; + fn add(mut self, rhs: Self) -> Multi { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Multi { + fn add_assign(&mut self, rhs: Self) { + if self.0.len() < rhs.0.len() { + self.0.resize(rhs.0.len(), 0); + } + for (a, b) in self.0.iter_mut().zip(rhs.0) { + *a += b; + } + } +} + +impl WindingNumber for Multi { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + let mut v = vec![0; tag + 1]; + v[tag] = if positive { 1 } else { -1 }; + Multi(v) + } + fn of_tag(&self, tag: usize) -> Self { + let mut v = vec![0; tag + 1]; + v[tag] = self.get(tag); + Multi(v) + } +} + +/// Convert a (filled) path to a closed BezPath: every subpath with ≥ 2 anchors is closed. +pub(crate) fn fill_bezpath(p: &PathData) -> BezPath { + let mut bp = BezPath::new(); + for sp in &p.subpaths { + if sp.anchors.len() < 2 { + continue; + } + if sp.closed { + sp.to_bezpath_into(&mut bp); + } else { + let mut c = sp.clone(); + c.closed = true; + c.to_bezpath_into(&mut bp); + } + } + bp +} + +/// Sweep tolerance for a set of paths (same scaling rule as linesweeper's own `binary_op`). +pub(crate) fn eps_for<'a>(paths: impl IntoIterator) -> Result { + let mut m: f64 = 0.0; + for p in paths { + for el in p.elements() { + for pt in el_points(el) { + if !pt.x.is_finite() || !pt.y.is_finite() { + return Err(PathOpsError::NonFinite); + } + m = m.max(pt.x.abs()).max(pt.y.abs()); + } + } + } + Ok((m * f64::EPSILON * 64.0).max(1e-6)) +} + +fn el_points(el: &kurbo::PathEl) -> impl Iterator { + use kurbo::PathEl::*; + let v: [Option; 3] = match *el { + MoveTo(p) | LineTo(p) => [Some(p), None, None], + QuadTo(a, b) => [Some(a), Some(b), None], + CurveTo(a, b, c) => [Some(a), Some(b), Some(c)], + ClosePath => [None, None, None], + }; + v.into_iter().flatten() +} + +/// An arrangement (planar subdivision) of N filled paths. +pub(crate) struct Arrangement { + pub(crate) top: Topology, + pub(crate) rules: Vec, + /// Output cleanup that keeps the inputs' anchors. + pub(crate) tidy: Tidy, +} + +/// Output cleanup settings: refit `precision`, and anchor positions that must survive (the +/// inputs' own anchors — only joints the sweep introduced are merged away). +#[derive(Clone, Debug, Default)] +pub(crate) struct Tidy { + pub(crate) precision: f64, + keep: Vec, + /// Input curves (with inflated bounds) used to rebuild split pieces exactly. + curves: Vec<(kurbo::Rect, CubicBez)>, +} + +impl Tidy { + /// Merge any smooth joint. + pub(crate) fn free(precision: f64) -> Self { + Self { precision, keep: Vec::new(), curves: Vec::new() } + } + /// Keep every on-curve point of `paths`. + pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator) -> Self { + let mut keep = Vec::new(); + let mut curves = Vec::new(); + for p in paths { + for s in p.segments() { + let seg = to_seg(s); + keep.push(seg.c.p0); + if !seg.line { + let r = seg.c.bounding_box().inflate(1e-6, 1e-6); + curves.push((r, seg.c)); + } + } + } + keep.sort_by(|a, b| a.x.total_cmp(&b.x)); + Self { precision, keep, curves } + } + fn is_kept(&self, p: Point) -> bool { + let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); + let lo = self.keep.partition_point(|q| q.x < p.x - tol); + self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + } +} + +impl Arrangement { + pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Result { + let eps = eps_for(paths.iter().map(|p| &p.0))?; + let top = Topology::::from_paths(paths.iter().enumerate().map(|(i, (p, _))| (p, i)), eps).map_err(|_| PathOpsError::OpenPath)?; + let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|p| &p.0)); + Ok(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy }) + } + + pub(crate) fn mask(&self, w: &Multi) -> Vec { + self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect() + } + + pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { + self.top.contours(|w| pred(&self.mask(w))) + } + + /// All distinct non-empty coverage masks that occur next to some edge, sorted. + pub(crate) fn distinct_masks(&self) -> Vec> { + let mut set = BTreeSet::new(); + for s in self.top.segment_indices() { + let h = s.first_half(); + for w in [self.top.winding_clockwise(h), self.top.winding_counter_clockwise(h)] { + let m = self.mask(w); + if m.iter().any(|&b| b) { + set.insert(m); + } + } + } + set.into_iter().collect() + } + + /// Vertices where three or more edges meet (true intersections / touch points). + pub(crate) fn junctions(&self) -> Vec { + let mut count: std::collections::HashMap<(u64, u64), (usize, Point)> = std::collections::HashMap::new(); + for s in self.top.segment_indices() { + for h in [s.first_half(), s.second_half()] { + let p = self.top.point(h).to_kurbo(); + count.entry((p.x.to_bits(), p.y.to_bits())).or_insert((0, p)).0 += 1; + } + } + let mut v: Vec = count.into_values().filter(|(c, _)| *c >= 3).map(|(_, p)| p).collect(); + v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + v + } +} + +/// Convert selected contours into a tidy compound path. +pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator, tidy: &Tidy) -> PathData { + let mut subpaths = Vec::new(); + for i in idx { + if is_sliver(&c[i].path, tidy.precision) { + continue; + } + if let Some(sp) = tidy_bezpath(&c[i].path, tidy) { + subpaths.push(sp); + } + } + PathData::new(subpaths) +} + +pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData { + contours_to_path(c, (0..c.contours().count()).map(ContourIdx), tidy) +} + +/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris +/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's +/// Pathfinder precision setting does. +fn is_sliver(bp: &BezPath, precision: f64) -> bool { + let a = bp.area().abs(); + let per = bp.perimeter(1e-6); + per <= 0.0 || 2.0 * a / per < precision +} + +/// One segment of a path: a cubic plus whether it is a straight line. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Seg { + pub(crate) c: CubicBez, + pub(crate) line: bool, +} + +impl Seg { + pub(crate) fn line(a: Point, b: Point) -> Self { + Self { c: CubicBez::new(a, a, b, b), line: true } + } +} + +pub(crate) fn to_seg(s: PathSeg) -> Seg { + match s { + PathSeg::Line(l) => Seg::line(l.p0, l.p1), + PathSeg::Quad(q) => { + let c = q.raise(); + Seg { c, line: is_straight(&c, 1e-9) } + } + PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) }, + } +} + +fn tidy_bezpath(bp: &BezPath, tidy: &Tidy) -> Option { + let segs: Vec = bp.segments().map(to_seg).collect(); + let segs = tidy_segments(segs, true, tidy); + segs_to_subpath(&segs, true) +} + +/// Build a subpath from a chain of segments. +pub(crate) fn segs_to_subpath(segs: &[Seg], closed: bool) -> Option { + if segs.is_empty() { + return None; + } + let n = segs.len(); + let mut anchors = Vec::with_capacity(n + 1); + for i in 0..n { + let s = segs[i]; + let p = s.c.p0; + let h_out = if s.line { p } else { s.c.p1 }; + let h_in = if i == 0 && !closed { + p + } else { + let prev = segs[(i + n - 1) % n]; + if prev.line { p } else { prev.c.p2 } + }; + anchors.push(Anchor::with_handles(p, h_in, h_out)); + } + if !closed { + let last = segs[n - 1]; + let p = last.c.p3; + let h_in = if last.line { p } else { last.c.p2 }; + anchors.push(Anchor::with_handles(p, h_in, p)); + } + if closed && anchors.len() < 2 { + return None; + } + Some(SubPath::new(anchors, closed)) +} + +/// Merge segments that were split from one curve (or collinear lines) back together, dropping +/// degenerate pieces. `closed` segments form a loop. +pub(crate) fn tidy_segments(segs: Vec, closed: bool, tidy: &Tidy) -> Vec { + let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max); + let tiny = (scale * 1e-9).max(1e-9); + let mut segs: Vec = segs + .into_iter() + .filter(|s| { + let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2); + r.width() + r.height() > tiny + }) + .collect(); + let n = segs.len(); + if n < 2 { + return segs; + } + // Joint i sits at the start of segment i. + let mergeable = |a: &Seg, b: &Seg| -> bool { + if a.line != b.line || tidy.is_kept(b.c.p0) { + return false; + } + let ta = end_tangent(&a.c); + let tb = start_tangent(&b.c); + ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02 + }; + if closed { + // Rotate so we start at a real corner (if there is one). + if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) { + segs.rotate_left(k); + } + } + let mut out: Vec = Vec::with_capacity(n); + let mut i = 0; + while i < n { + let mut cur = segs[i]; + let mut j = i + 1; + while j < n && mergeable(&segs[j - 1], &segs[j]) { + match try_merge(&segs[i..=j], tidy) { + Some(m) => { + cur = m; + j += 1; + } + None => break, + } + } + out.push(cur); + i = j; + } + out +} + +fn try_merge(run: &[Seg], tidy: &Tidy) -> Option { + let precision = tidy.precision; + let first = run[0].c; + let last = run[run.len() - 1].c; + if run[0].line { + let chord = last.p3 - first.p0; + let len = chord.hypot(); + if len < 1e-12 { + return None; + } + let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5); + return ok.then(|| Seg::line(first.p0, last.p3)); + } + if let Some(c) = exact_merge(run, tidy) { + return Some(Seg { c, line: false }); + } + let mut pts = Vec::with_capacity(run.len() * 12 + 1); + for (k, s) in run.iter().enumerate() { + sample(&s.c, 12, &mut pts, k == 0); + } + let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last)); + if err > precision { + return None; + } + Some(Seg { c, line: false }) +} + +/// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run. +fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { + use kurbo::{ParamCurve, ParamCurveNearest}; + let a = run[0].c.p0; + let b = run[run.len() - 1].c.p3; + let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs()); + let tol = 1e-6 * scale; + for (r, c) in &tidy.curves { + if !r.contains(a) || !r.contains(b) { + continue; + } + let na = c.nearest(a, 1e-12); + let nb = c.nearest(b, 1e-12); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol { + continue; + } + let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0); + if !on_curve || (na.t - nb.t).abs() < 1e-12 { + continue; + } + let sub = if na.t < nb.t { + c.subsegment(na.t..nb.t) + } else { + let r = c.subsegment(nb.t..na.t); + CubicBez::new(r.p3, r.p2, r.p1, r.p0) + }; + return Some(CubicBez::new(a, sub.p1, sub.p2, b)); + } + None +} + +/// Resolve self-intersections / overlaps of a single filled path into simple, consistently +/// oriented contours (outer contours and holes). +pub fn normalize(path: &PathData, rule: FillRule) -> PathData { + try_normalize(path, rule).unwrap_or_default() +} + +/// Fallible [`normalize`]. +pub fn try_normalize(path: &PathData, rule: FillRule) -> Result { + let bp = fill_bezpath(path); + let c = normalize_bez(&bp, rule)?; + Ok(all_contours_to_path(&c, &Tidy::keeping(DEFAULT_PRECISION, [&bp]))) +} + +pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result { + let eps = eps_for([bp])?; + let top = Topology::::from_path(bp, eps).map_err(|_| PathOpsError::OpenPath)?; + Ok(top.contours(|w| inside(rule, *w))) +} + +/// Boolean operation between two filled paths. Returns an empty path on invalid (non-finite) input. +pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp) -> PathData { + try_boolean(a, a_rule, b, b_rule, op, DEFAULT_PRECISION).unwrap_or_default() +} + +/// Boolean operation with an explicit refit `precision`; errors on non-finite input. +pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result { + let arr = Arrangement::new(&[(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?; + let c = arr.contours(|m| op.apply(m[0], m[1])); + let tidy = Tidy { precision, ..arr.tidy }; + Ok(all_contours_to_path(&c, &tidy)) +} + +/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a +/// point is inside `paths[i]` under its fill rule. +pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData { + let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); + match Arrangement::new(&bps) { + Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy), + Err(_) => PathData::default(), + } +} + +/// Union of many filled paths. Each input is first normalised on its own, then all are swept +/// together with a single integer winding number, which keeps this fast for thousands of shapes. +pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { + let mut all = BezPath::new(); + for (p, r) in paths { + let bp = fill_bezpath(p); + if bp.elements().is_empty() { + continue; + } + let Ok(c) = normalize_bez(&bp, *r) else { continue }; + for ct in c.contours() { + all.extend(ct.path.iter()); + } + } + if all.elements().is_empty() { + return PathData::default(); + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::>().iter()); + match normalize_bez(&all, FillRule::NonZero) { + Ok(c) => all_contours_to_path(&c, &tidy), + Err(_) => PathData::default(), + } +} + +/// Unsigned filled area of a path under a fill rule (normalises first, so it is exact for +/// self-overlapping input). +pub fn area(path: &PathData, rule: FillRule) -> f64 { + let bp = fill_bezpath(path); + match normalize_bez(&bp, rule) { + Ok(c) => c.contours().map(|ct| ct.path.area()).sum::().abs(), + Err(_) => 0.0, + } +} diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs new file mode 100644 index 000000000..067277c0d --- /dev/null +++ b/crates/pathops/src/edit.rs @@ -0,0 +1,366 @@ +//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, +//! Join, Split Into Grid. + +use drawcraft_geom::{Anchor, PathData, SubPath}; +use kurbo::{ParamCurveArclen, Point, Rect, Vec2}; + +use crate::boolean::{Seg, segs_to_subpath}; +use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent}; + +/// Options for [`simplify_with`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SimplifyOptions { + /// Maximum distance (document points) between the original and simplified path. + pub tolerance: f64, + /// Anchors where the path turns by more than this many degrees stay corners. + pub corner_angle_deg: f64, + /// Produce only straight segments (Illustrator's "Convert to Straight Lines"). + pub straight_lines: bool, +} + +impl Default for SimplifyOptions { + fn default() -> Self { + Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false } + } +} + +/// Simplify with the default corner threshold (30°). +pub fn simplify(path: &PathData, tolerance: f64) -> PathData { + simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) +} + +fn subpath_segs(sp: &SubPath) -> Vec { + (0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect() +} + +fn turn_deg(a: &Seg, b: &Seg) -> f64 { + let d = end_tangent(&a.c).dot(start_tangent(&b.c)).clamp(-1.0, 1.0); + d.acos().to_degrees() +} + +/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold. +pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { + let tol = opts.tolerance.max(1e-6); + let mut out = Vec::new(); + for sp in &path.subpaths { + let mut segs = subpath_segs(sp); + let n = segs.len(); + if n == 0 { + out.push(sp.clone()); + continue; + } + let corner_at = |segs: &[Seg], i: usize| -> bool { + if !sp.closed && (i == 0 || i == n) { + return true; + } + turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg + }; + let mut corners: Vec = (0..n).filter(|&i| corner_at(&segs, i)).collect(); + if sp.closed { + if let Some(&k) = corners.first() { + segs.rotate_left(k); + corners = corners.iter().map(|&c| c - k).collect(); + } else { + corners = vec![0]; + } + corners.push(n); + } else if corners.last() != Some(&n) { + corners.push(n); + } + let mut result: Vec = Vec::new(); + for w in corners.windows(2) { + let run = &segs[w[0]..w[1]]; + if run.is_empty() { + continue; + } + let mut pts = Vec::new(); + for (k, s) in run.iter().enumerate() { + let len = s.c.arclen(1e-6); + let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); + sample(&s.c, m, &mut pts, k == 0); + } + if opts.straight_lines { + let keep = rdp(&pts, tol); + result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); + continue; + } + let t0 = start_tangent(&run[0].c); + let t1 = end_tangent(&run[run.len() - 1].c); + for c in fit_cubics(&pts, t0, t1, tol) { + let line = is_straight(&c, tol * 0.02); + result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); + } + } + if let Some(s) = segs_to_subpath(&result, sp.closed) { + out.push(s); + } + } + PathData::new(out) +} + +/// Ramer–Douglas–Peucker polyline reduction. +fn rdp(pts: &[Point], tol: f64) -> Vec { + if pts.len() < 3 { + return pts.to_vec(); + } + let mut keep = vec![false; pts.len()]; + keep[0] = true; + keep[pts.len() - 1] = true; + let mut stack = vec![(0, pts.len() - 1)]; + while let Some((a, b)) = stack.pop() { + let (pa, pb) = (pts[a], pts[b]); + let chord = pb - pa; + let len = chord.hypot(); + let mut best = (0.0, a); + for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) { + let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len }; + if d > best.0 { + best = (d, i); + } + } + if best.0 > tol { + keep[best.1] = true; + stack.push((a, best.1)); + stack.push((best.1, b)); + } + } + pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect() +} + +/// Object → Path → Smooth: pull handles towards Catmull-Rom-like tangents. `amount` ∈ [0, 1] +/// (0 = unchanged, 1 = fully smooth). Endpoints of open paths keep their handles. +pub fn smooth(path: &PathData, amount: f64) -> PathData { + let t = amount.clamp(0.0, 1.0); + let mut out = path.clone(); + for sp in &mut out.subpaths { + let n = sp.anchors.len(); + if n < 3 { + continue; + } + let orig = sp.anchors.clone(); + for i in 0..n { + if !sp.closed && (i == 0 || i == n - 1) { + continue; + } + let prev = orig[(i + n - 1) % n].p; + let next = orig[(i + 1) % n].p; + let p = orig[i].p; + let Some(dir) = crate::fit::unit(next - prev) else { continue }; + let tin = p - dir * (p.distance(prev) / 3.0); + let tout = p + dir * (p.distance(next) / 3.0); + let a = &orig[i]; + let h_in = a.h_in.lerp(tin, t); + let h_out = a.h_out.lerp(tout, t); + sp.anchors[i] = Anchor::with_handles(p, h_in, h_out); + } + } + out +} + +/// Remove anchors that can be deleted without changing the shape by more than `tolerance` +/// (collinear corner points, duplicate points, and curve joints that one cubic can replace). +pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData { + let tol = tolerance.max(1e-9); + let mut out = Vec::new(); + for sp in &path.subpaths { + let mut segs = subpath_segs(sp); + if segs.is_empty() { + out.push(sp.clone()); + continue; + } + // Drop zero-length segments. + segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9)); + let min_segs = if sp.closed { 2 } else { 1 }; + let mut changed = true; + while changed && segs.len() > min_segs { + changed = false; + let n = segs.len(); + let joints = if sp.closed { n } else { n - 1 }; + for j in 0..joints { + let (i0, i1) = if sp.closed { ((j + n - 1) % n, j) } else { (j, j + 1) }; + if let Some(m) = merge_pair(&segs[i0], &segs[i1], tol) { + segs[i0] = m; + segs.remove(i1); + if sp.closed && i1 == 0 { + // The merged segment now starts the loop. + let last = segs.pop().expect("non-empty"); + segs.insert(0, last); + } + changed = true; + break; + } + } + } + if let Some(s) = segs_to_subpath(&segs, sp.closed) { + out.push(s); + } + } + PathData::new(out) +} + +fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option { + if a.line && b.line { + let chord = b.c.p3 - a.c.p0; + let len = chord.hypot(); + if len < 1e-12 { + return None; + } + let mid = a.c.p3 - a.c.p0; + let along = mid.dot(chord) / len; + return ((mid.cross(chord) / len).abs() <= tol && along >= 0.0 && along <= len).then(|| Seg::line(a.c.p0, b.c.p3)); + } + // Only merge across (near-)smooth joints. + if end_tangent(&a.c).dot(start_tangent(&b.c)) < 0.99 { + return None; + } + let mut pts = Vec::new(); + sample(&a.c, 16, &mut pts, true); + sample(&b.c, 16, &mut pts, false); + let (c, err, _) = fit_single(&pts, start_tangent(&a.c), end_tangent(&b.c)); + (err <= tol).then_some(Seg { c, line: false }) +} + +/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment. +pub fn add_anchor_points(path: &PathData) -> PathData { + let mut out = path.clone(); + for sp in &mut out.subpaths { + for seg in (0..sp.segment_count()).rev() { + sp.insert_anchor(seg, 0.5); + } + } + out +} + +/// Axis for [`average`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AverageAxis { + /// Align on a common horizontal line (same y). + Horizontal, + /// Align on a common vertical line (same x). + Vertical, + /// Collapse to the common centroid. + Both, +} + +/// Object → Path → Average: move the selected anchors `(subpath, anchor)` (with their handles) +/// to their average position along `axis`. +pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData { + let mut out = path.clone(); + let pts: Vec = selection.iter().filter_map(|&(s, a)| path.subpaths.get(s).and_then(|sp| sp.anchors.get(a)).map(|a| a.p)).collect(); + if pts.is_empty() { + return out; + } + let c = pts.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2()) / pts.len() as f64; + for &(s, a) in selection { + if let Some(an) = out.anchor_mut(s, a) { + let d = match axis { + AverageAxis::Horizontal => Vec2::new(0.0, c.y - an.p.y), + AverageAxis::Vertical => Vec2::new(c.x - an.p.x, 0.0), + AverageAxis::Both => c - an.p.to_vec2(), + }; + an.translate(d); + } + } + out +} + +/// Object → Path → Join. Open subpaths of all inputs are joined nearest-endpoint-first into one +/// open path (endpoints closer than `tolerance` are merged into a single anchor, otherwise a +/// straight segment connects them). A single open subpath is closed instead. Closed subpaths +/// pass through unchanged. +pub fn join(paths: &[PathData], tolerance: f64) -> PathData { + let mut closed: Vec = Vec::new(); + let mut open: Vec = Vec::new(); + for p in paths { + for sp in &p.subpaths { + if sp.closed || sp.anchors.len() < 2 { + if !sp.anchors.is_empty() { + closed.push(sp.clone()); + } + } else { + open.push(sp.clone()); + } + } + } + if open.len() == 1 { + let mut sp = open.pop().expect("one"); + let n = sp.anchors.len(); + if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance { + let last = sp.anchors.pop().expect("n > 2"); + let f = &mut sp.anchors[0]; + *f = Anchor::with_handles(f.p, last.h_in, f.h_out); + } else { + let f = &mut sp.anchors[0]; + f.h_in = f.p; + let l = sp.anchors.last_mut().expect("n >= 2"); + l.h_out = l.p; + } + sp.closed = true; + closed.push(sp); + return PathData::new(closed); + } + while open.len() > 1 { + // Find the closest pair of endpoints on different subpaths. + let mut best = (f64::INFINITY, 0, 0, false, false); + for i in 0..open.len() { + for j in (i + 1)..open.len() { + for ei in [false, true] { + for ej in [false, true] { + let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p; + let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p; + let d = pi.distance(pj); + if d < best.0 { + best = (d, i, j, ei, ej); + } + } + } + } + } + let (d, i, j, ei, ej) = best; + let mut b = open.remove(j); + let a = &mut open[i]; + if !ei { + a.reverse(); + } + if ej { + b.reverse(); + } + if d <= tolerance { + let first = b.anchors.remove(0); + let l = a.anchors.last_mut().expect("len>=2"); + *l = Anchor::with_handles(l.p, l.h_in, first.h_out); + } else { + let l = a.anchors.last_mut().expect("len>=2"); + l.h_out = l.p; + b.anchors[0].h_in = b.anchors[0].p; + } + a.anchors.extend(b.anchors); + } + closed.extend(open); + PathData::new(closed) +} + +/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with `gutter` spacing +/// (row-major, top-left first). +pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec { + if rows == 0 || cols == 0 { + return Vec::new(); + } + let w = (rect.width() - gutter * (cols as f64 - 1.0)) / cols as f64; + let h = (rect.height() - gutter * (rows as f64 - 1.0)) / rows as f64; + if w <= 0.0 || h <= 0.0 { + return Vec::new(); + } + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let x = rect.x0 + c as f64 * (w + gutter); + let y = rect.y0 + r as f64 * (h + gutter); + out.push(PathData::single(SubPath::polyline( + &[Point::new(x, y), Point::new(x + w, y), Point::new(x + w, y + h), Point::new(x, y + h)], + true, + ))); + } + } + out +} diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs new file mode 100644 index 000000000..83e101841 --- /dev/null +++ b/crates/pathops/src/fit.rs @@ -0,0 +1,228 @@ +//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end +//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from +//! Graphics Gems, written from the published description). + +use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2}; + +/// Maximum Newton reparameterisation rounds per fit. +const MAX_REPARAM: usize = 6; + +/// Unit vector or `None` if (nearly) zero. +pub(crate) fn unit(v: Vec2) -> Option { + let l = v.hypot(); + if l > 1e-12 && l.is_finite() { Some(v / l) } else { None } +} + +/// Direction the curve leaves `p0` (robust to retracted handles). +pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 { + unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction. +pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 { + unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// Fit a chain of cubics through `pts` with maximum (parametric) error `tol`. +/// +/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point +/// (forward direction). Consecutive output cubics are G1 continuous. +pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { + let mut out = Vec::new(); + let pts = dedup(pts); + if pts.len() < 2 { + return out; + } + fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0); + out +} + +fn dedup(pts: &[Point]) -> Vec { + let mut v: Vec = Vec::with_capacity(pts.len()); + for &p in pts { + if v.last().is_none_or(|q: &Point| q.distance(p) > 1e-12) { + v.push(p); + } + } + v +} + +fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec, depth: usize) { + let n = pts.len(); + if n == 2 { + let d = pts[0].distance(pts[1]) / 3.0; + out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1])); + return; + } + let (c, err, split) = fit_single(pts, t0, t1); + if err <= tol || depth > 40 { + out.push(c); + return; + } + // Split at the worst point with a centre tangent estimated from its neighbours. + let split = split.clamp(1, n - 2); + let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0); + fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1); + fit_rec(&pts[split..], tc, t1, tol, out, depth + 1); +} + +/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of +/// the worst point). +pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { + let mut u = chord_params(pts); + let mut best = generate(pts, &u, t0, t1); + let (mut best_err, mut best_split) = max_error(pts, &best, &u); + for _ in 0..MAX_REPARAM { + for (i, ui) in u.iter_mut().enumerate() { + *ui = newton(&best, pts[i], *ui); + } + let c = generate(pts, &u, t0, t1); + let (e, s) = max_error(pts, &c, &u); + if e < best_err { + best = c; + best_err = e; + best_split = s; + } else { + break; + } + } + (best, best_err, best_split) +} + +fn chord_params(pts: &[Point]) -> Vec { + let mut u = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + u.push(0.0); + for w in pts.windows(2) { + acc += w[0].distance(w[1]); + u.push(acc); + } + if acc > 0.0 { + for x in &mut u { + *x /= acc; + } + } + u +} + +fn bern(t: f64) -> [f64; 4] { + let s = 1.0 - t; + [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] +} + +/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents. +fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez { + let p0 = pts[0]; + let p3 = *pts.last().unwrap(); + let dir_in = -t1; // handle at the end points backwards + let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0); + for (p, &t) in pts.iter().zip(u) { + let b = bern(t); + let a0 = t0 * b[1]; + let a1 = dir_in * b[2]; + c00 += a0.dot(a0); + c01 += a0.dot(a1); + c11 += a1.dot(a1); + let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point(); + x0 += a0.dot(tmp); + x1 += a1.dot(tmp); + } + let det = c00 * c11 - c01 * c01; + let seg = p0.distance(p3); + let eps = 1e-6 * seg; + let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) }; + if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps { + al = seg / 3.0; + ar = seg / 3.0; + } + // Guard against wild handles on nearly-degenerate data. + let cap = seg * 4.0 + 1e-9; + al = al.min(cap); + ar = ar.min(cap); + CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3) +} + +fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) { + let mut e = 0.0; + let mut idx = pts.len() / 2; + for (i, (p, &t)) in pts.iter().zip(u).enumerate() { + let d = c.eval(t).distance(*p); + if d > e { + e = d; + idx = i; + } + } + (e, idx) +} + +fn newton(c: &CubicBez, p: Point, t: f64) -> f64 { + let d1 = c.deriv(); + let d2 = d1.deriv(); + let q = c.eval(t) - p; + let q1 = d1.eval(t).to_vec2(); + let q2 = d2.eval(t).to_vec2(); + let num = q.dot(q1); + let den = q1.dot(q1) + q.dot(q2); + if den.abs() < 1e-12 { + return t; + } + let nt = t - num / den; + if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t } +} + +/// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord? +pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool { + let chord = c.p3 - c.p0; + let len = chord.hypot(); + if len < 1e-12 { + return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol; + } + let dir = chord / len; + [c.p1, c.p2].iter().all(|&h| { + let v = h - c.p0; + let along = v.dot(dir); + v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol + }) +} + +/// Sample a cubic at `n+1` evenly spaced parameters (including both ends). +pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec, include_start: bool) { + let start = if include_start { 0 } else { 1 }; + for i in start..=n { + out.push(c.eval(i as f64 / n as f64)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fits_a_cubic_exactly() { + let c = CubicBez::new((0.0, 0.0), (30.0, 60.0), (70.0, 60.0), (100.0, 0.0)); + let mut pts = Vec::new(); + sample(&c, 40, &mut pts, true); + let out = fit_cubics(&pts, start_tangent(&c), end_tangent(&c), 0.05); + assert_eq!(out.len(), 1); + assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]); + } + + #[test] + fn splits_when_needed() { + // A zig-zag cannot be fitted with one cubic. + let pts: Vec = (0..=40).map(|i| Point::new(i as f64, ((i as f64) * 0.5).sin() * 10.0)).collect(); + let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 0.1); + assert!(out.len() > 1); + for w in out.windows(2) { + assert!(w[0].p3.distance(w[1].p0) < 1e-9); + } + } + + #[test] + fn straight_detection() { + let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0)); + assert!(is_straight(&c, 1e-6)); + let c = CubicBez::new((0.0, 0.0), (3.0, 1.0), (6.0, 0.0), (9.0, 0.0)); + assert!(!is_straight(&c, 1e-6)); + } +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs new file mode 100644 index 000000000..ad1c4c900 --- /dev/null +++ b/crates/pathops/src/lib.rs @@ -0,0 +1,29 @@ +//! DrawCraft path operations: booleans (Pathfinder), shape builder regions, offset, outline stroke, +//! simplify and the other Object → Path commands. +//! +//! Booleans are curve-preserving: `linesweeper`'s robust sweep-line works directly on cubic +//! Béziers, and the pieces it splits curves into are refitted afterwards so results carry few +//! anchors. See the crate README for the API overview. +#![forbid(unsafe_code)] + +mod boolean; +mod edit; +mod fit; +mod offset; +mod pathfinder; + +pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; +pub use edit::{ + AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid, +}; +pub use offset::{Cap, Join, offset_path, outline_stroke}; +pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; + +/// Errors from fallible operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PathOpsError { + #[error("input contains NaN or infinite coordinates")] + NonFinite, + #[error("input path could not be closed")] + OpenPath, +} diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs new file mode 100644 index 000000000..873e64504 --- /dev/null +++ b/crates/pathops/src/offset.rs @@ -0,0 +1,76 @@ +//! Offset Path and Outline Stroke. + +use drawcraft_geom::{FillRule, PathData, SubPath}; +use kurbo::{BezPath, PathEl, Stroke, StrokeOpts}; + +pub use kurbo::{Cap, Join}; + +use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, fill_bezpath, normalize_bez}; + +/// Flattening/fit tolerance handed to kurbo's stroker. +const STROKE_TOL: f64 = 1e-3; + +fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { + let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); + close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) +} + +/// kurbo's stroker may leave the outline of an open path without a final `ClosePath`. +fn close_all(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let mut open = false; + for el in bp.iter() { + match el { + PathEl::MoveTo(_) => { + if open { + out.close_path(); + } + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + out.push(el); + } + if open { + out.close_path(); + } + out +} + +/// Object → Path → Outline Stroke: the filled area painted by stroking `path` with `width`. +/// Overlaps produced by the stroker are removed, so the result is a clean compound path. +pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData { + if width <= 0.0 || !width.is_finite() || path.is_empty() { + return PathData::default(); + } + let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit); + if s.elements().is_empty() { + return PathData::default(); + } + normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default() +} + +/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it. +/// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a +/// stroke of width `2|delta|` (butt caps). +pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData { + if !delta.is_finite() || path.is_empty() { + return PathData::default(); + } + let closed = PathData::new(path.subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect()); + let open: Vec = path.subpaths.iter().filter(|s| !s.closed && s.anchors.len() > 1).cloned().collect(); + let d = delta.abs(); + let fill = fill_bezpath(&closed); + let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() }; + let open_bp = if d > 0.0 && !open.is_empty() { + stroke_bez(&PathData::new(open).to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) + } else { + BezPath::new() + }; + let Ok(arr) = Arrangement::new(&[(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else { + return PathData::default(); + }; + let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) }; + all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION)) +} diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs new file mode 100644 index 000000000..b6d808e4e --- /dev/null +++ b/crates/pathops/src/pathfinder.rs @@ -0,0 +1,275 @@ +//! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes +//! (index 0 = back-most, last = front-most). + +use drawcraft_geom::{FillRule, PathData}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _}; + +use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; + +/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); +/// results carry the key of the object whose paint they keep. +#[derive(Clone, Debug, PartialEq)] +pub struct Shape { + pub path: PathData, + pub rule: FillRule, + pub key: u64, +} + +impl Shape { + pub fn new(path: PathData, rule: FillRule, key: u64) -> Self { + Self { path, rule, key } + } +} + +/// The ten Pathfinder panel operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PathfinderOp { + /// Shape mode: union of everything; result keeps the front-most key. + Unite, + /// Shape mode: back-most minus everything in front; keeps the back-most key. + MinusFront, + /// Shape mode: area covered by *all* shapes; keeps the front-most key. + Intersect, + /// Shape mode: area covered by an odd number of shapes; keeps the front-most key. + Exclude, + /// Split into every non-overlapping face; each face keeps the key of the front-most shape + /// covering it. + Divide, + /// Remove hidden parts; one result per shape that remains visible (no merging). + Trim, + /// Trim, then merge touching/overlapping visible parts with the same key. + Merge, + /// Keep the visible parts of lower shapes that lie inside the front-most shape (which is removed). + Crop, + /// Split every edge at intersections; returns *open* paths keyed by the shape they bound. + Outline, + /// Front-most minus everything behind; keeps the front-most key. + MinusBack, +} + +/// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face). +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + /// Face outline (outer contour plus holes, consistently oriented). + pub path: PathData, + /// Indices of the input shapes covering this face, ascending. + pub sources: Vec, +} + +impl Region { + /// Front-most covering shape. + pub fn top(&self) -> usize { + *self.sources.last().expect("regions are never uncovered") + } + /// Does the face contain `p`? + pub fn contains(&self, p: Point) -> bool { + self.path.to_bezpath().winding(p) != 0 + } +} + +fn arrangement(shapes: &[Shape]) -> Option { + let bps: Vec<(BezPath, FillRule)> = shapes.iter().map(|s| (fill_bezpath(&s.path), s.rule)).collect(); + Arrangement::new(&bps).ok() +} + +fn topmost(m: &[bool]) -> Option { + m.iter().rposition(|&b| b) +} + +fn one(path: PathData, key: u64) -> Vec { + if path.is_empty() { Vec::new() } else { vec![Shape::new(path, FillRule::NonZero, key)] } +} + +/// Run a Pathfinder operation over `shapes` (back → front). +pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { + let n = shapes.len(); + if n == 0 { + return Vec::new(); + } + let front_key = shapes[n - 1].key; + if op == PathfinderOp::Unite { + let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect(); + return one(unite_all(&v), front_key); + } + let Some(arr) = arrangement(shapes) else { return Vec::new() }; + let p = &arr.tidy; + match op { + PathfinderOp::Unite => unreachable!(), + PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key), + PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key), + PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key), + PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key), + PathfinderOp::Divide => regions_of(&arr) + .into_iter() + .map(|r| { + let key = shapes[r.top()].key; + Shape::new(r.path, FillRule::NonZero, key) + }) + .collect(), + PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(), + PathfinderOp::Merge => { + let mut keys: Vec = Vec::new(); + for s in shapes { + if !keys.contains(&s.key) { + keys.push(s.key); + } + } + let mut out = Vec::new(); + for k in keys { + let c = arr.contours(|m| topmost(m).is_some_and(|t| shapes[t].key == k)); + for g in c.grouped() { + out.extend(one(contours_to_path(&c, g, p), k)); + } + } + out + } + PathfinderOp::Crop => { + if n < 2 { + return Vec::new(); + } + (0..n - 1) + .flat_map(|i| { + let c = arr.contours(|m| m[n - 1] && topmost(&m[..n - 1]) == Some(i)); + one(all_contours_to_path(&c, p), shapes[i].key) + }) + .collect() + } + PathfinderOp::Outline => outline_edges(&arr, shapes), + } +} + +fn regions_of(arr: &Arrangement) -> Vec { + let mut out = Vec::new(); + for mask in arr.distinct_masks() { + let c = arr.contours(|m| m == mask.as_slice()); + let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + for g in c.grouped() { + let path = contours_to_path(&c, g, &arr.tidy); + if !path.is_empty() { + out.push(Region { path, sources: sources.clone() }); + } + } + } + out +} + +/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape, +/// split wherever coverage changes). +pub fn regions(shapes: &[Shape]) -> Vec { + arrangement(shapes).map(|a| regions_of(&a)).unwrap_or_default() +} + +/// The face under `point`, if any (Shape Builder hover/click). +pub fn region_at(shapes: &[Shape], point: Point) -> Option { + regions(shapes).into_iter().find(|r| r.contains(point)) +} + +/// Merge Shape Builder regions into one path (their union). +pub fn merge_regions(regions: &[&Region]) -> PathData { + let v: Vec<(&PathData, FillRule)> = regions.iter().map(|r| (&r.path, FillRule::NonZero)).collect(); + unite_all(&v) +} + +/// Pathfinder Outline: split every shape's boundary at junctions of the arrangement. +fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { + let junctions = arr.junctions(); + let scale = shapes.iter().filter_map(|s| s.path.control_bounds()).map(|r| r.width().max(r.height())).fold(1.0, f64::max); + let tol = scale * 1e-7; + // (chain, key, source index) + let mut chains: Vec<(Vec, u64)> = Vec::new(); + for s in shapes { + for sp in &s.path.subpaths { + if sp.anchors.len() < 2 { + continue; + } + let mut sp = sp.clone(); + sp.closed = true; + // Split each segment at junctions; record which piece starts are breaks. + let mut pieces: Vec<(Seg, bool)> = Vec::new(); + for i in 0..sp.segment_count() { + let c = sp.segment(i); + let line = sp.segment_is_line(i); + let mut ts: Vec = junctions + .iter() + .filter_map(|&j| { + let nr = c.nearest(j, 1e-9); + (nr.distance_sq.sqrt() <= tol.max(1e-6)).then_some(nr.t) + }) + .collect(); + ts.sort_by(f64::total_cmp); + let start_is_junction = ts.first().is_some_and(|&t| t < 1e-9); + let mut cuts: Vec = vec![0.0]; + cuts.extend(ts.into_iter().filter(|&t| t > 1e-9 && t < 1.0 - 1e-9)); + cuts.push(1.0); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + for (k, w) in cuts.windows(2).enumerate() { + let sub = c.subsegment(w[0]..w[1]); + let seg = if line { Seg::line(sub.p0, sub.p3) } else { Seg { c: sub, line: false } }; + pieces.push((seg, if k == 0 { start_is_junction } else { true })); + } + } + // Chain pieces between breaks. + let first_break = pieces.iter().position(|(_, b)| *b); + match first_break { + None => { + let segs: Vec = pieces.into_iter().map(|(s, _)| s).collect(); + chains.push((segs, s.key)); + } + Some(k) => { + pieces.rotate_left(k); + let mut cur: Vec = Vec::new(); + for (seg, brk) in pieces { + if brk && !cur.is_empty() { + chains.push((std::mem::take(&mut cur), s.key)); + } + cur.push(seg); + } + if !cur.is_empty() { + chains.push((cur, s.key)); + } + } + } + } + } + // De-duplicate coincident chains, keeping the front-most key (later shapes overwrite). + let mut out: Vec<(Vec, u64)> = Vec::new(); + let sig = |c: &[Seg]| -> (Point, Point, Point) { + let a = c[0].c.p0; + let b = c[c.len() - 1].c.p3; + let mid = mid_point(c); + (a, b, mid) + }; + for (chain, key) in chains { + let (a, b, m) = sig(&chain); + let near = |p: Point, q: Point| p.distance(q) <= tol.max(1e-6) * 10.0; + if let Some(e) = out.iter_mut().find(|(c, _)| { + let (a2, b2, m2) = sig(c); + near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2))) + }) { + e.1 = key; + } else { + out.push((chain, key)); + } + } + out.into_iter() + .filter_map(|(chain, key)| { + // Chain joints are the shapes' own anchors, so there is nothing to re-merge. + let sp = segs_to_subpath(&chain, false)?; + Some(Shape::new(PathData::single(sp), FillRule::NonZero, key)) + }) + .collect() +} + +fn mid_point(c: &[Seg]) -> Point { + let lens: Vec = c.iter().map(|s| kurbo::ParamCurveArclen::arclen(&s.c, 1e-6)).collect(); + let total: f64 = lens.iter().sum(); + let mut acc = 0.0; + for (s, l) in c.iter().zip(&lens) { + if acc + l >= total / 2.0 && *l > 0.0 { + let t = kurbo::ParamCurveArclen::inv_arclen(&s.c, total / 2.0 - acc, 1e-6); + return s.c.eval(t); + } + acc += l; + } + c[0].c.p0 +} diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/ops.rs new file mode 100644 index 000000000..2ac9eecd2 --- /dev/null +++ b/crates/pathops/tests/ops.rs @@ -0,0 +1,437 @@ +use std::f64::consts::PI; + +use drawcraft_geom::{FillRule, PathData, SubPath}; +use drawcraft_pathops::*; +use kurbo::{ParamCurve, Point, Rect, Shape as _}; + +const NZ: FillRule = FillRule::NonZero; + +fn rect(x: f64, y: f64, w: f64, h: f64) -> PathData { + PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6)) +} + +fn circle(cx: f64, cy: f64, r: f64) -> PathData { + PathData::from_bezpath(&kurbo::Circle::new((cx, cy), r).to_path(1e-4)) +} + +fn ar(p: &PathData) -> f64 { + area(p, NZ) +} + +fn close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0) +} + +fn shape(p: PathData, key: u64) -> Shape { + Shape::new(p, NZ, key) +} + +fn has_curves(p: &PathData) -> bool { + p.anchors().any(|(_, _, a)| a.has_in() || a.has_out()) +} + +#[test] +fn union_of_rects() { + let u = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Union); + assert!(close(ar(&u), 175.0, 1e-9)); + assert_eq!(u.subpaths.len(), 1); + assert_eq!(u.anchor_count(), 8); +} + +#[test] +fn intersect_of_rects() { + let i = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Intersect); + assert!(close(ar(&i), 25.0, 1e-9)); + assert_eq!(i.anchor_count(), 4); +} + +#[test] +fn difference_of_rects() { + let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Difference); + assert!(close(ar(&d), 75.0, 1e-9)); + assert_eq!(d.anchor_count(), 6); +} + +#[test] +fn xor_of_rects() { + let x = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Xor); + assert!(close(ar(&x), 150.0, 1e-9)); +} + +#[test] +fn disjoint_union_keeps_both() { + let u = boolean(&rect(0.0, 0.0, 1.0, 1.0), NZ, &rect(5.0, 5.0, 1.0, 1.0), NZ, BoolOp::Union); + assert_eq!(u.subpaths.len(), 2); + assert!(close(ar(&u), 2.0, 1e-9)); +} + +#[test] +fn a_minus_a_is_empty() { + let c = circle(3.0, 4.0, 7.0); + assert!(boolean(&c, NZ, &c, NZ, BoolOp::Difference).is_empty()); +} + +#[test] +fn union_idempotent_rect() { + let r = rect(1.0, 2.0, 3.0, 4.0); + let u = boolean(&r, NZ, &r, NZ, BoolOp::Union); + assert_eq!(u.anchor_count(), 4); + assert!(close(ar(&u), 12.0, 1e-9)); +} + +#[test] +fn circle_union_is_curve_preserving_and_compact() { + let u = boolean(&circle(0.0, 0.0, 10.0), NZ, &circle(10.0, 0.0, 10.0), NZ, BoolOp::Union); + assert!(has_curves(&u)); + assert!(u.anchor_count() <= 12, "anchors: {}", u.anchor_count()); + // Lens area: 2 r² acos(d/2r) − (d/2)√(4r² − d²) + let (r, d): (f64, f64) = (10.0, 10.0); + let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt(); + assert!(close(ar(&u), 2.0 * PI * r * r - lens, 1e-3)); +} + +#[test] +fn circle_intersect_rect_keeps_curve() { + let i = boolean(&circle(0.0, 0.0, 10.0), NZ, &rect(0.0, -20.0, 20.0, 40.0), NZ, BoolOp::Intersect); + assert!(has_curves(&i)); + assert!(close(ar(&i), PI * 50.0, 1e-3)); + assert!(i.anchor_count() <= 5, "anchors: {}", i.anchor_count()); +} + +#[test] +fn hole_from_difference() { + let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(3.0, 3.0, 4.0, 4.0), NZ, BoolOp::Difference); + assert_eq!(d.subpaths.len(), 2); + assert!(close(ar(&d), 84.0, 1e-9)); + // Consistently oriented: works with both fill rules. + assert!(close(area(&d, FillRule::EvenOdd), 84.0, 1e-9)); +} + +#[test] +fn fill_rules_differ_for_nested_same_direction() { + let mut p = rect(0.0, 0.0, 10.0, 10.0); + p.subpaths.extend(rect(3.0, 3.0, 4.0, 4.0).subpaths); + assert!(close(area(&p, FillRule::NonZero), 100.0, 1e-9)); + assert!(close(area(&p, FillRule::EvenOdd), 84.0, 1e-9)); + let u = boolean(&p, FillRule::EvenOdd, &rect(20.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union); + assert!(close(ar(&u), 85.0, 1e-9)); +} + +#[test] +fn open_paths_are_closed_for_fill() { + let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], false)); + assert!(close(ar(&tri), 50.0, 1e-9)); +} + +#[test] +fn non_finite_input_errors() { + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)], true)); + let r = try_boolean(&bad, NZ, &rect(0.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union, DEFAULT_PRECISION); + assert_eq!(r, Err(PathOpsError::NonFinite)); +} + +#[test] +fn normalize_figure_eight() { + let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true)); + let n = normalize(&p, NZ); + assert_eq!(n.subpaths.len(), 2); + assert!(close(ar(&n), 50.0, 1e-9)); +} + +fn stack() -> Vec { + vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 5.0, 10.0, 10.0), 2)] +} + +#[test] +fn pf_unite_takes_front_key() { + let r = pathfinder(PathfinderOp::Unite, &stack()); + assert_eq!(r.len(), 1); + assert_eq!(r[0].key, 2); + assert!(close(ar(&r[0].path), 175.0, 1e-9)); +} + +#[test] +fn pf_minus_front_and_back() { + let r = pathfinder(PathfinderOp::MinusFront, &stack()); + assert_eq!(r[0].key, 1); + assert!(close(ar(&r[0].path), 75.0, 1e-9)); + let r = pathfinder(PathfinderOp::MinusBack, &stack()); + assert_eq!(r[0].key, 2); + assert!(close(ar(&r[0].path), 75.0, 1e-9)); +} + +#[test] +fn pf_intersect_and_exclude_three() { + let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 0.0, 10.0, 10.0), 2), shape(rect(8.0, 0.0, 10.0, 10.0), 3)]; + let i = pathfinder(PathfinderOp::Intersect, &s); + assert!(close(ar(&i[0].path), 20.0, 1e-9)); + // Odd coverage: [0,5)=1, [5,8)=2, [8,10)=3, [10,15)=2, [15,18)=1 → 5+2+3 = 10 wide. + let x = pathfinder(PathfinderOp::Exclude, &s); + assert!(close(ar(&x[0].path), 100.0, 1e-9)); +} + +#[test] +fn pf_divide_faces() { + let r = pathfinder(PathfinderOp::Divide, &stack()); + assert_eq!(r.len(), 3); + let total: f64 = r.iter().map(|s| ar(&s.path)).sum(); + assert!(close(total, 175.0, 1e-9)); + let overlap = r.iter().find(|s| close(ar(&s.path), 25.0, 1e-9)).unwrap(); + assert_eq!(overlap.key, 2); +} + +#[test] +fn pf_trim_removes_hidden() { + let r = pathfinder(PathfinderOp::Trim, &stack()); + assert_eq!(r.len(), 2); + assert!(close(ar(&r[0].path), 75.0, 1e-9)); + assert!(close(ar(&r[1].path), 100.0, 1e-9)); +} + +#[test] +fn pf_merge_same_key() { + let mut s = stack(); + s[1].key = 1; + let r = pathfinder(PathfinderOp::Merge, &s); + assert_eq!(r.len(), 1); + assert!(close(ar(&r[0].path), 175.0, 1e-9)); + let r = pathfinder(PathfinderOp::Merge, &stack()); + assert_eq!(r.len(), 2); +} + +#[test] +fn pf_crop() { + let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2), shape(rect(5.0, 0.0, 10.0, 5.0), 3)]; + let r = pathfinder(PathfinderOp::Crop, &s); + assert_eq!(r.len(), 2); + assert!(r.iter().all(|x| close(ar(&x.path), 25.0, 1e-9))); + assert_eq!(r[0].key, 1); +} + +#[test] +fn pf_outline_edges() { + let r = pathfinder(PathfinderOp::Outline, &stack()); + assert_eq!(r.len(), 4, "{r:?}"); + assert!(r.iter().all(|s| s.path.subpaths.iter().all(|sp| !sp.closed))); + let len: f64 = r.iter().map(|s| s.path.length()).sum(); + assert!(close(len, 80.0, 1e-9)); +} + +#[test] +fn pf_outline_dedups_shared_edges() { + // Two squares sharing an edge exactly. + let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2)]; + let r = pathfinder(PathfinderOp::Outline, &s); + let len: f64 = r.iter().map(|s| s.path.length()).sum(); + assert!(close(len, 70.0, 1e-9), "len {len}"); +} + +#[test] +fn regions_and_region_at() { + let s = stack(); + let rs = regions(&s); + assert_eq!(rs.len(), 3); + let hit = region_at(&s, Point::new(7.0, 7.0)).unwrap(); + assert_eq!(hit.sources, vec![0, 1]); + assert!(close(ar(&hit.path), 25.0, 1e-9)); + assert!(region_at(&s, Point::new(100.0, 100.0)).is_none()); + let refs: Vec<&Region> = rs.iter().collect(); + assert!(close(ar(&merge_regions(&refs)), 175.0, 1e-9)); +} + +#[test] +fn offset_circle_grows() { + let o = offset_path(&circle(0.0, 0.0, 10.0), 3.0, Join::Round, 4.0); + assert!(close(ar(&o), PI * 169.0, 2e-3), "{}", ar(&o)); + assert!(o.anchor_count() <= 16, "anchors {}", o.anchor_count()); +} + +#[test] +fn offset_circle_insets() { + let o = offset_path(&circle(0.0, 0.0, 10.0), -4.0, Join::Round, 4.0); + assert!(close(ar(&o), PI * 36.0, 2e-3), "{}", ar(&o)); +} + +#[test] +fn offset_rect_joins() { + let r = rect(0.0, 0.0, 20.0, 10.0); + assert!(close(ar(&offset_path(&r, 2.0, Join::Miter, 4.0)), 24.0 * 14.0, 1e-6)); + assert_eq!(offset_path(&r, 2.0, Join::Miter, 4.0).anchor_count(), 4); + assert!(close(ar(&offset_path(&r, 2.0, Join::Bevel, 4.0)), 24.0 * 14.0 - 4.0 * 2.0, 1e-6)); + assert!(close(ar(&offset_path(&r, 2.0, Join::Round, 4.0)), 200.0 + 2.0 * 2.0 * 30.0 + PI * 4.0, 1e-3)); + assert!(close(ar(&offset_path(&r, -2.0, Join::Miter, 4.0)), 16.0 * 6.0, 1e-6)); + assert!(offset_path(&r, -6.0, Join::Miter, 4.0).is_empty()); +} + +#[test] +fn outline_stroke_of_line() { + let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false)); + let o = outline_stroke(&l, 4.0, Cap::Butt, Join::Miter, 4.0); + assert!(close(ar(&o), 200.0, 1e-6)); + let o = outline_stroke(&l, 4.0, Cap::Square, Join::Miter, 4.0); + assert!(close(ar(&o), 216.0, 1e-6)); +} + +#[test] +fn outline_stroke_of_square_is_ring() { + let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0); + assert_eq!(o.subpaths.len(), 2); + assert!(close(ar(&o), 144.0 - 64.0, 1e-6)); +} + +#[test] +fn outline_stroke_self_overlap_is_cleaned() { + // A zig-zag whose stroke overlaps itself at the joins. + let z = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 1.0), Point::new(10.0, 2.0)], false)); + let o = outline_stroke(&z, 3.0, Cap::Round, Join::Round, 4.0); + assert!(!o.is_empty()); + // Normalised output: its signed area equals its fill area. + let signed: f64 = o.subpaths.iter().map(|s| s.area()).sum::().abs(); + assert!(close(signed, ar(&o), 1e-6)); +} + +fn dense_circle(r: f64, n: usize) -> PathData { + let pts: Vec = + (0..n).map(|i| Point::new(r * (i as f64 / n as f64 * 2.0 * PI).cos(), r * (i as f64 / n as f64 * 2.0 * PI).sin())).collect(); + PathData::single(SubPath::polyline(&pts, true)) +} + +fn max_deviation(orig: &PathData, simp: &PathData) -> f64 { + let bp = orig.to_bezpath(); + let mut worst: f64 = 0.0; + for seg in bp.segments() { + for i in 0..=8 { + let p = seg.eval(i as f64 / 8.0); + worst = worst.max(simp.nearest(p).unwrap().4); + } + } + worst +} + +#[test] +fn simplify_dense_circle() { + let c = dense_circle(50.0, 200); + let s = simplify(&c, 0.5); + assert!(s.anchor_count() <= 12, "anchors {}", s.anchor_count()); + assert!(max_deviation(&c, &s) <= 0.5 + 1e-6); + assert!(s.subpaths[0].closed); +} + +#[test] +fn simplify_keeps_square_corners() { + let mut pts = Vec::new(); + for (a, b) in [((0.0, 0.0), (10.0, 0.0)), ((10.0, 0.0), (10.0, 10.0)), ((10.0, 10.0), (0.0, 10.0)), ((0.0, 10.0), (0.0, 0.0))] { + for i in 0..5 { + let t = i as f64 / 5.0; + pts.push(Point::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + } + } + let sq = PathData::single(SubPath::polyline(&pts, true)); + let s = simplify(&sq, 0.1); + assert_eq!(s.anchor_count(), 4); + assert!(!has_curves(&s)); +} + +#[test] +fn simplify_straight_lines_option() { + let c = dense_circle(50.0, 200); + let s = simplify_with(&c, &SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() }); + assert!(!has_curves(&s)); + assert!(s.anchor_count() < 40); + assert!(max_deviation(&c, &s) <= 1.0 + 1e-6); +} + +#[test] +fn smooth_rounds_corners() { + let s = smooth(&rect(0.0, 0.0, 10.0, 10.0), 1.0); + assert!(s.anchors().all(|(_, _, a)| a.has_in() && a.has_out())); + let same = smooth(&rect(0.0, 0.0, 10.0, 10.0), 0.0); + assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0)); +} + +#[test] +fn remove_redundant_collinear_and_curve() { + let p = PathData::single(SubPath::polyline( + &[Point::new(0.0, 0.0), Point::new(5.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], + true, + )); + assert_eq!(remove_redundant_points(&p, 1e-6).anchor_count(), 4); + let c = add_anchor_points(&circle(0.0, 0.0, 10.0)); + let n0 = c.anchor_count(); + let r = remove_redundant_points(&c, 1e-3); + assert!(r.anchor_count() < n0, "{} vs {}", r.anchor_count(), n0); + assert!(close(ar(&r), ar(&c), 1e-4)); +} + +#[test] +fn add_anchor_points_doubles() { + let c = circle(0.0, 0.0, 10.0); + let a = add_anchor_points(&c); + assert_eq!(a.anchor_count(), 2 * c.anchor_count()); + assert!(close(ar(&a), ar(&c), 1e-9)); + let r = add_anchor_points(&rect(0.0, 0.0, 10.0, 10.0)); + assert!(r.anchors().any(|(_, _, a)| a.p == Point::new(5.0, 0.0))); +} + +#[test] +fn average_axes() { + let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 4.0), Point::new(5.0, 8.0)], false)); + let h = average(&p, &[(0, 0), (0, 1)], AverageAxis::Horizontal); + assert_eq!(h.subpaths[0].anchors[0].p, Point::new(0.0, 2.0)); + assert_eq!(h.subpaths[0].anchors[1].p, Point::new(10.0, 2.0)); + let b = average(&p, &[(0, 0), (0, 1), (0, 2)], AverageAxis::Both); + assert!(b.subpaths[0].anchors.iter().all(|a| a.p.distance(Point::new(5.0, 4.0)) < 1e-12)); + let v = average(&p, &[(0, 0), (0, 1)], AverageAxis::Vertical); + assert_eq!(v.subpaths[0].anchors[0].p.x, 5.0); +} + +#[test] +fn join_two_open_paths() { + let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)); + let b = PathData::single(SubPath::polyline(&[Point::new(20.0, 5.0), Point::new(10.0, 0.0)], false)); + let j = join(&[a.clone(), b], 1e-6); + assert_eq!(j.subpaths.len(), 1); + assert_eq!(j.anchor_count(), 3); + assert!(!j.subpaths[0].closed); + let c = PathData::single(SubPath::polyline(&[Point::new(12.0, 0.0), Point::new(20.0, 0.0)], false)); + let j = join(&[a, c], 1e-6); + assert_eq!(j.anchor_count(), 4); +} + +#[test] +fn join_single_path_closes() { + let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0)], false)); + let j = join(&[a], 1e-6); + assert!(j.subpaths[0].closed); + assert_eq!(j.anchor_count(), 3); + let b = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0), Point::new(0.0, 0.0)], false)); + assert_eq!(join(&[b], 1e-6).anchor_count(), 3); +} + +#[test] +fn grid_split() { + let g = split_into_grid(Rect::new(0.0, 0.0, 100.0, 50.0), 2, 3, 5.0); + assert_eq!(g.len(), 6); + let w = (100.0 - 10.0) / 3.0; + let h = (50.0 - 5.0) / 2.0; + assert!(g.iter().all(|c| close(ar(c), w * h, 1e-9))); + assert_eq!(g[5].bounds().unwrap().x1, 100.0); + assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, 3, 1.0).is_empty()); +} + +#[test] +#[ignore] +fn timing_unite_1000_circles() { + let mut seed = 0x1234_5678_u64; + let mut rnd = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1_000_000) as f64 / 1_000_000.0 + }; + let circles: Vec = (0..1000).map(|i| shape(circle(rnd() * 1000.0, rnd() * 1000.0, 5.0 + rnd() * 30.0), i)).collect(); + let t = std::time::Instant::now(); + let r = pathfinder(PathfinderOp::Unite, &circles); + println!("unite 1000 circles: {} ms, {} anchors", t.elapsed().as_millis(), r[0].path.anchor_count()); + assert!(!r.is_empty()); +} diff --git a/crates/pathops/tests/props.rs b/crates/pathops/tests/props.rs new file mode 100644 index 000000000..b124e593b --- /dev/null +++ b/crates/pathops/tests/props.rs @@ -0,0 +1,128 @@ +use std::f64::consts::PI; + +use drawcraft_geom::{FillRule, PathData, SubPath}; +use drawcraft_pathops::*; +use kurbo::{ParamCurve, Point, Rect, Shape as _}; +use proptest::prelude::*; + +const NZ: FillRule = FillRule::NonZero; + +fn arb_shape() -> impl Strategy { + prop_oneof![ + (0.0..50.0f64, 0.0..50.0f64, 1.0..40.0f64, 1.0..40.0f64) + .prop_map(|(x, y, w, h)| PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6))), + (0.0..50.0f64, 0.0..50.0f64, 1.0..25.0f64).prop_map(|(x, y, r)| PathData::from_bezpath(&kurbo::Circle::new((x, y), r).to_path(1e-4))), + ] +} + +fn close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, failure_persistence: None, ..ProptestConfig::default() })] + + #[test] + fn inclusion_exclusion(a in arb_shape(), b in arb_shape()) { + let u = area(&boolean(&a, NZ, &b, NZ, BoolOp::Union), NZ); + let i = area(&boolean(&a, NZ, &b, NZ, BoolOp::Intersect), NZ); + let (aa, ab) = (area(&a, NZ), area(&b, NZ)); + prop_assert!(close(u + i, aa + ab, 2e-3), "u={u} i={i} a={aa} b={ab}"); + } + + #[test] + fn grid_rects_with_shared_edges(v in proptest::collection::vec((0u8..6, 0u8..6, 1u8..5, 1u8..5), 2..6)) { + // Integer rectangles: lots of coincident edges and touching corners. + let shapes: Vec = v.iter().enumerate().map(|(i, &(x, y, w, h))| { + let r = Rect::new(x as f64, y as f64, (x + w) as f64, (y + h) as f64); + Shape::new(PathData::from_bezpath(&r.to_path(1e-6)), NZ, i as u64) + }).collect(); + let faces = pathfinder(PathfinderOp::Divide, &shapes); + let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum(); + let u = pathfinder(PathfinderOp::Unite, &shapes); + let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0); + // Exact cell count on the integer grid. + let mut cells = 0; + for cx in 0..12u8 { for cy in 0..12u8 { + if v.iter().any(|&(x, y, w, h)| cx >= x && cx < x + w && cy >= y && cy < y + h) { cells += 1; } + }} + prop_assert!(close(ua, cells as f64, 1e-9), "union {ua} cells {cells}"); + prop_assert!(close(sum, cells as f64, 1e-9), "divide {sum} cells {cells}"); + let trim: f64 = pathfinder(PathfinderOp::Trim, &shapes).iter().map(|f| area(&f.path, NZ)).sum(); + prop_assert!(close(trim, cells as f64, 1e-9)); + } + + #[test] + fn difference_and_xor_consistent(a in arb_shape(), b in arb_shape()) { + let d1 = area(&boolean(&a, NZ, &b, NZ, BoolOp::Difference), NZ); + let d2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Difference), NZ); + let x = area(&boolean(&a, NZ, &b, NZ, BoolOp::Xor), NZ); + prop_assert!(close(d1 + d2, x, 2e-3)); + } + + #[test] + fn self_difference_empty(a in arb_shape()) { + prop_assert!(boolean(&a, NZ, &a, NZ, BoolOp::Difference).is_empty()); + } + + #[test] + fn unite_idempotent(a in arb_shape()) { + let u = boolean(&a, NZ, &a, NZ, BoolOp::Union); + prop_assert!(close(area(&u, NZ), area(&a, NZ), 1e-3)); + prop_assert!(u.anchor_count() <= a.anchor_count() + 4, "{} vs {}", u.anchor_count(), a.anchor_count()); + let uu = boolean(&u, NZ, &u, NZ, BoolOp::Union); + prop_assert!(close(area(&uu, NZ), area(&u, NZ), 1e-6)); + } + + #[test] + fn divide_sums_to_union(a in arb_shape(), b in arb_shape(), c in arb_shape()) { + let shapes = vec![Shape::new(a, NZ, 1), Shape::new(b, NZ, 2), Shape::new(c, NZ, 3)]; + let faces = pathfinder(PathfinderOp::Divide, &shapes); + let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum(); + let u = pathfinder(PathfinderOp::Unite, &shapes); + let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0); + prop_assert!(close(sum, ua, 2e-3), "sum={sum} union={ua}"); + } + + #[test] + fn offset_circle_area(r in 5.0..60.0f64, d in -4.0..10.0f64) { + let c = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), r).to_path(1e-5)); + let o = offset_path(&c, d, Join::Round, 4.0); + let expect = PI * (r + d) * (r + d); + prop_assert!(close(area(&o, NZ), expect, 3e-3), "got {} expect {}", area(&o, NZ), expect); + } + + #[test] + fn outline_stroke_line_area(x in -50.0..50.0f64, y in -50.0..50.0f64, len in 1.0..100.0f64, ang in 0.0..6.0f64, w in 0.5..10.0f64) { + let a = Point::new(x, y); + let b = Point::new(x + len * ang.cos(), y + len * ang.sin()); + let l = PathData::single(SubPath::polyline(&[a, b], false)); + let o = outline_stroke(&l, w, Cap::Butt, Join::Miter, 4.0); + prop_assert!(close(area(&o, NZ), len * w, 1e-6)); + } + + #[test] + fn simplify_within_tolerance( + amps in proptest::collection::vec(0.0..8.0f64, 3), + tol in 0.05..2.0f64, + ) { + // A wobbly closed curve sampled as a dense polyline. + let n = 240; + let pts: Vec = (0..n).map(|i| { + let t = i as f64 / n as f64 * 2.0 * PI; + let r = 60.0 + amps[0] * (2.0 * t).sin() + amps[1] * (3.0 * t).cos() + amps[2] * (5.0 * t).sin(); + Point::new(r * t.cos(), r * t.sin()) + }).collect(); + let p = PathData::single(SubPath::polyline(&pts, true)); + let s = simplify(&p, tol); + prop_assert!(s.anchor_count() < n); + let bp = p.to_bezpath(); + for seg in bp.segments() { + for k in 0..=4 { + let q = seg.eval(k as f64 / 4.0); + let d = s.nearest(q).unwrap().4; + prop_assert!(d <= tol * 1.01 + 1e-6, "deviation {d} > {tol}"); + } + } + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 000000000..f9be38cec --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "drawcraft-pdf" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft PDF export and import" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-doc = { workspace = true } +kurbo = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 000000000..9476b2ef2 --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,2 @@ +//! DrawCraft PDF export and import +#![forbid(unsafe_code)] diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 000000000..d739eaede --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "drawcraft-render" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft renderer: document to pixels (vello_cpu backend)" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-text = { workspace = true } +vello_cpu = { workspace = true } +image = { workspace = true } +log = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 000000000..363f2eb3f --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1,513 @@ +//! DrawCraft renderer: document → premultiplied RGBA pixels. +//! +//! The backend is `vello_cpu` (SIMD, sparse strips). Callers give a *view transform* mapping +//! document points to output pixels; the renderer culls by bounds, evaluates appearance stacks +//! (multiple fills/strokes, opacity, blend modes, stroke alignment, dashes), clip groups, +//! gradients, images and text (via `drawcraft-text` glyph outlines). +#![forbid(unsafe_code)] + +mod paint; + +use std::collections::HashMap; +use std::sync::Arc; + +use drawcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use drawcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; +use vello_cpu::kurbo; +use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; +use vello_cpu::{Pixmap, RenderContext, Resources}; + +pub use vello_cpu; + +/// Rendering options. +#[derive(Clone, Debug)] +pub struct RenderOptions { + /// Outline (wireframe) view: 1 px black paths, no paint. + pub outline: bool, + /// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent. + pub background: Option<[u8; 4]>, + /// Draw artboards as white rectangles (screen view). Export sets this to false. + pub artboards: bool, + /// Objects not to draw (e.g. the object being edited by a live drag preview). + pub hidden: Vec, + /// Draw template layers dimmed (50%). + pub dim_templates: bool, +} + +impl Default for RenderOptions { + fn default() -> Self { + Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true } + } +} + +/// A rendered image (premultiplied RGBA8, row-major). +pub struct Rendered { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Rendered { + /// Un-premultiplied RGBA8 copy. + pub fn to_straight(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.chunks_exact_mut(4) { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + out + } + /// Encode as PNG. + pub fn to_png(&self) -> Vec { + let mut buf = Vec::new(); + let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size"); + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode"); + buf + } + /// Straight-alpha RGBA at (x, y). + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + let p = &self.pixels[i..i + 4]; + let a = p[3] as u32; + if a == 0 { + return [0, 0, 0, 0]; + } + let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8; + [un(p[0]), un(p[1]), un(p[2]), p[3]] + } +} + +/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). +pub struct Renderer { + ctx: Option, + resources: Resources, + images: HashMap>, + /// Statistics of the last frame. + pub stats: FrameStats, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameStats { + pub drawn: usize, + pub culled: usize, + pub micros: u64, +} + +impl Default for Renderer { + fn default() -> Self { + Self::new() + } +} + +struct Frame<'a> { + doc: &'a Document, + view: Affine, + /// Visible region in document coordinates (for culling). + visible: Rect, + /// Size of one output pixel in document units. + px: f64, + opts: &'a RenderOptions, +} + +impl Renderer { + pub fn new() -> Self { + Self { ctx: None, resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default() } + } + + /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). + pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let start = now(); + let w = width.clamp(1, u16::MAX as u32) as u16; + let h = height.clamp(1, u16::MAX as u32) as u16; + let mut ctx = match self.ctx.take() { + Some(mut c) if c.width() == w && c.height() == h => { + c.reset(); + c + } + _ => RenderContext::new(w, h), + }; + self.stats = FrameStats::default(); + let inv = view.inverse(); + let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let frame = Frame { doc, view, visible, px, opts }; + + if let Some(bg) = opts.background { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); + ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); + } + if opts.artboards && !opts.outline { + ctx.set_transform(view); + ctx.set_paint(peniko::Color::WHITE); + for ab in &doc.artboards { + ctx.fill_rect(&ab.rect); + } + } + for layer in &doc.layers { + self.draw_node(&mut ctx, &frame, layer, false); + } + ctx.flush(); + let mut pm = Pixmap::new(w, h); + ctx.render(&mut pm, &mut self.resources); + self.ctx = Some(ctx); + self.stats.micros = now().saturating_sub(start); + Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() } + } + + /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white. + pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { + let w = (region.width() * scale).round().max(1.0) as u32; + let h = (region.height() * scale).round().max(1.0) as u32; + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() }; + self.render(doc, w, h, view, &opts) + } + + /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. + pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { + let n = doc.node(id)?; + let b = n.visual_bounds()?; + let 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; + let mut ctx = RenderContext::new(w, w); + let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; + self.draw_node(&mut ctx, &frame, n, true); + ctx.flush(); + let mut pm = Pixmap::new(w, w); + ctx.render(&mut pm, &mut self.resources); + Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) + } + + fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) { + if !force && (!n.visible || f.opts.hidden.contains(&n.id)) { + return; + } + if let Some(b) = n.visual_bounds() { + let pad = f.px * 2.0; + if !rects_overlap(b.inflate(pad, pad), f.visible) { + self.stats.culled += n.count(); + return; + } + } else if !n.is_container() { + return; + } + let outline = f.opts.outline; + let mut layers = 0; + let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; + let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity }; + if !outline && (opacity < 1.0 || n.blend != drawcraft_color::BlendMode::Normal || n.isolate) { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None); + layers += 1; + } + match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children { + self.draw_node(ctx, f, c, false); + } + } + NodeKind::Group { children, clip: true } => { + let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath()); + match (clip, outline) { + (Some(clip), false) => { + ctx.set_transform(f.view); + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.push_clip_layer(&clip); + for c in children.iter().skip(1) { + self.draw_node(ctx, f, c, false); + } + ctx.pop_layer(); + } + _ => { + for c in children { + self.draw_node(ctx, f, c, false); + } + } + } + } + NodeKind::Path { path, rule, guide, .. } => { + let bp = path.to_bezpath(); + if *guide { + self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]); + } else { + self.draw_shape(ctx, f, n, &bp, *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.draw_shape(ctx, f, n, &bp, *rule); + } + NodeKind::Text(t) => self.draw_text(ctx, f, n, t), + NodeKind::Image(im) => self.draw_image(ctx, f, im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.draw_node(ctx, f, &art, true); + } + } + } + self.stats.drawn += 1; + for _ in 0..layers { + ctx.pop_layer(); + } + } + + fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) { + ctx.set_transform(Affine::IDENTITY); + let mut screen = bp.clone(); + screen.apply_affine(f.view); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.set_paint(peniko::Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3])); + ctx.stroke_path(&screen); + } + + fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if f.opts.outline { + self.hairline(ctx, f, bp, [0, 0, 0, 255]); + 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; + } + let layered = fl.opacity < 1.0 || fl.blend != drawcraft_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, bounds, f.doc) { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(bp); + } + 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, bp, rule, st, bounds); + } + } + } + } + + fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { + let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None); + } + ctx.set_transform(f.view); + let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath)); + let width = match st.align { + StrokeAlign::Center => st.width, + _ if closed => st.width * 2.0, + _ => st.width, + }; + // Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel). + let width = width.max(f.px * 0.5); + let mut stroke = kurbo::Stroke::new(width) + .with_join(match st.join { + LineJoin::Miter => kurbo::Join::Miter, + LineJoin::Round => kurbo::Join::Round, + LineJoin::Bevel => kurbo::Join::Bevel, + }) + .with_caps(match st.cap { + LineCap::Butt => kurbo::Cap::Butt, + LineCap::Round => kurbo::Cap::Round, + LineCap::Square => kurbo::Cap::Square, + }) + .with_miter_limit(st.miter_limit); + if let Some(d) = &st.dash + && d.pattern.iter().any(|v| *v > 0.0) + { + let mut pat = d.pattern.clone(); + if pat.len() % 2 == 1 { + pat.extend(pat.clone()); + } + stroke = stroke.with_dashes(d.offset, pat); + } + ctx.set_stroke(stroke); + let inside = st.align == StrokeAlign::Inside && closed; + if inside { + ctx.set_fill_rule(fill_rule(rule)); + ctx.push_clip_layer(bp); + } + if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) { + ctx.stroke_path(bp); + } + if inside { + ctx.pop_layer(); + } + if st.align == StrokeAlign::Outside && closed { + // Punch out the interior. + ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut)); + ctx.set_paint(peniko::Color::BLACK); + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(bp); + ctx.set_blend_mode(BlendMode::default()); + } + // Arrowheads. + if st.start_arrow.is_some() || st.end_arrow.is_some() { + for (arrow, head) in paint::arrowheads(bp, st) { + let _ = arrow; + if paint::set_paint(ctx, &st.paint, bounds, f.doc) { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(&head); + } + } + } + if layered { + ctx.pop_layer(); + } + } + + fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { + let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let xf = f.view * t.xf; + if f.opts.outline { + for g in &layout.glyphs { + let mut p = g.outline.clone(); + p.apply_affine(xf); + ctx.set_transform(Affine::IDENTITY); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.set_paint(peniko::Color::BLACK); + ctx.stroke_path(&p); + } + return; + } + let tb = t.xf.transform_rect_bbox(layout.bounds); + // Object-level appearance fills/strokes apply on top of character fills (like Illustrator). + for (i, run) in t.runs.iter().enumerate() { + let mut path = BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i) { + path.extend(g.outline.iter()); + } + if path.elements().is_empty() { + continue; + } + ctx.set_transform(xf); + ctx.set_fill_rule(peniko::Fill::NonZero); + if paint::set_paint(ctx, &run.style.fill, layout.bounds, f.doc) { + ctx.fill_path(&path); + } + if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, layout.bounds, f.doc) { + ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); + ctx.stroke_path(&path); + } + } + if !n.appearance.items.is_empty() { + let mut all = BezPath::new(); + for g in &layout.glyphs { + all.extend(g.outline.iter()); + } + all.apply_affine(t.xf); + for item in &n.appearance.items { + ctx.set_transform(f.view); + match item { + AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(&all); + } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { + ctx.set_stroke(kurbo::Stroke::new(st.width)); + ctx.stroke_path(&all); + } + _ => {} + } + } + } + } + + fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &drawcraft_doc::ImageObject) { + let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); + if f.opts.outline { + let mut p = rect.to_path(0.1); + p.apply_affine(im.xf); + self.hairline(ctx, f, &p, [0, 0, 0, 255]); + return; + } + let pm = match self.images.get(&im.key) { + Some(p) => p.clone(), + None => { + let Some(blob) = f.doc.images.get(&im.key) else { return }; + let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return }; + let pm = Arc::new(pm); + self.images.insert(im.key.clone(), pm.clone()); + pm + } + }; + let sx = im.width as f64 / pm.width().max(1) as f64; + let sy = im.height as f64 / pm.height().max(1) as f64; + ctx.set_transform(f.view * im.xf); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&rect); + ctx.reset_paint_transform(); + } +} + +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +fn fill_rule(r: FillRule) -> peniko::Fill { + match r { + FillRule::NonZero => peniko::Fill::NonZero, + FillRule::EvenOdd => peniko::Fill::EvenOdd, + } +} + +pub(crate) fn blend_mode(b: drawcraft_color::BlendMode) -> BlendMode { + use drawcraft_color::BlendMode as B; + let mix = match b { + B::Normal => Mix::Normal, + B::Darken => Mix::Darken, + B::Multiply => Mix::Multiply, + B::ColorBurn => Mix::ColorBurn, + B::Lighten => Mix::Lighten, + B::Screen => Mix::Screen, + B::ColorDodge => Mix::ColorDodge, + B::Overlay => Mix::Overlay, + B::SoftLight => Mix::SoftLight, + B::HardLight => Mix::HardLight, + B::Difference => Mix::Difference, + B::Exclusion => Mix::Exclusion, + B::Hue => Mix::Hue, + B::Saturation => Mix::Saturation, + B::Color => Mix::Color, + B::Luminosity => Mix::Luminosity, + }; + BlendMode::new(mix, Compose::SrcOver) +} + +#[cfg(not(target_arch = "wasm32"))] +fn now() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0) +} +#[cfg(target_arch = "wasm32")] +fn now() -> u64 { + 0 +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs new file mode 100644 index 000000000..6c6eda204 --- /dev/null +++ b/crates/render/src/paint.rs @@ -0,0 +1,157 @@ +//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding. + +use drawcraft_color::{GradientKind, Paint}; +use drawcraft_doc::{Arrowhead, Document, StrokeLayer}; +use drawcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use vello_cpu::RenderContext; +use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv}; +use vello_cpu::peniko::{self, ColorStop}; + +fn color(c: &drawcraft_color::Color, alpha: f32) -> peniko::Color { + let [r, g, b, a] = c.to_rgba8(alpha); + peniko::Color::from_rgba8(r, g, b, a) +} + +/// Set the context paint. Returns false if nothing should be drawn. +pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Document) -> bool { + match p { + Paint::None => false, + Paint::Solid { color: c, .. } => { + ctx.set_paint(color(c, 1.0)); + true + } + Paint::Gradient(g) => { + let geom = g.resolve(bounds); + let stops: Vec = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect(); + if stops.is_empty() { + return false; + } + let grad = match g.gradient.kind { + GradientKind::Radial => { + let r = geom.length().max(1e-6) as f32; + let angle = (geom.end - geom.start).atan2(); + // Aspect ratio squashes the circle along the perpendicular of the gradient vector. + ctx.set_paint_transform( + 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()), + ); + peniko::Gradient::new_radial(geom.start, r).with_stops(stops.as_slice()) + } + _ => { + ctx.reset_paint_transform(); + let (s, e) = + if geom.start.distance(geom.end) < 1e-9 { (geom.start, geom.start + Vec2::new(1.0, 0.0)) } else { (geom.start, geom.end) }; + peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice()) + } + }; + ctx.set_paint(grad); + true + } + Paint::Pattern { .. } => { + // Patterns render as a neutral mid-grey until pattern swatches land (M10.5). + ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255)); + true + } + } +} + +/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates. +pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> { + let segs: Vec = bp.segments().collect(); + let mut out = Vec::new(); + let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out }; + if let Some(a) = st.start_arrow { + let p = first.eval(0.0); + let d = tangent(first, 0.0) * -1.0; + out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0))); + } + if let Some(a) = st.end_arrow { + let p = last.eval(1.0); + let d = tangent(last, 1.0); + out.push((a, head(a, p, d, 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 } +} + +/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`. +fn head(kind: Arrowhead, tip: 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 +} + +/// Decode encoded image bytes into a premultiplied pixmap. +pub fn decode_pixmap(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let data: Vec = img + .pixels() + .map(|p| { + let a = p[3] as u16; + let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16)) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs new file mode 100644 index 000000000..9936add77 --- /dev/null +++ b/crates/render/src/tests.rs @@ -0,0 +1,197 @@ +use super::*; +use drawcraft_color::{Color, Gradient, GradientPaint, Paint}; +use drawcraft_doc::{Appearance, Node}; +use drawcraft_geom::shapes; + +fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node { + let id = d.alloc_id(); + Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w)) +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +#[test] +fn fills_red_rect() { + let mut d = Document::new(100.0, 100.0); + let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let d = { + let mut d2 = d.clone(); + d2.insert(Some(d.layers[0].id), 0, n).unwrap(); + d2 + }; + let r = render(&d); + assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]); + assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]); +} + +#[test] +fn stroke_draws_outline_only() { + let mut d = Document::new(100.0, 100.0); + let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = render(&d); + assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]); + assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]); +} + +#[test] +fn inside_stroke_stays_inside() { + let mut d = Document::new(100.0, 100.0); + let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0); + n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = render(&d); + assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]); + assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]); +} + +#[test] +fn outside_stroke_stays_outside() { + let mut d = Document::new(100.0, 100.0); + let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0); + n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = render(&d); + assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]); + assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]); +} + +#[test] +fn opacity_blends() { + let mut d = Document::new(100.0, 100.0); + let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + n.opacity = 0.5; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let p = render(&d).pixel(50, 50); + assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}"); +} + +#[test] +fn multiply_blend() { + let mut d = Document::new(100.0, 100.0); + let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0); + let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0); + b.blend = drawcraft_color::BlendMode::Multiply; + let l = d.layers[0].id; + d.insert(Some(l), 0, a).unwrap(); + d.insert(Some(l), 1, b).unwrap(); + assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]); +} + +#[test] +fn linear_gradient_goes_white_to_black() { + let mut d = Document::new(100.0, 100.0); + let n = + rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = render(&d); + assert!(r.pixel(2, 50)[0] > 240); + assert!(r.pixel(97, 50)[0] < 15); + let m = r.pixel(50, 50)[0] as i32; + assert!((m - 128).abs() < 12, "{m}"); +} + +#[test] +fn clip_group_clips() { + let mut d = Document::new(100.0, 100.0); + let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let gid = d.alloc_id(); + let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let r = render(&d); + assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]); + assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]); +} + +#[test] +fn hidden_and_culled() { + let mut d = Document::new(100.0, 100.0); + let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + n.visible = false; + let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d.insert(Some(l), 1, far).unwrap(); + let mut rr = Renderer::new(); + let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]); + assert_eq!(rr.stats.culled, 1); +} + +#[test] +fn view_transform_zooms() { + let mut d = Document::new(100.0, 100.0); + let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default()); + assert_eq!(r.pixel(45, 45)[3], 255); + assert_eq!(r.pixel(55, 55)[3], 0); +} + +#[test] +fn outline_mode_no_fill() { + let mut d = Document::new(100.0, 100.0); + let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() }); + assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]); + assert!(r.pixel(10, 50)[0] < 200); +} + +#[test] +fn artboards_white_on_pasteboard() { + let d = Document::new(50.0, 50.0); + let r = Renderer::new().render( + &d, + 100, + 100, + Affine::IDENTITY, + &RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() }, + ); + assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]); + assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]); +} + +#[test] +fn region_export_png() { + let mut d = Document::new(100.0, 100.0); + let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true); + assert_eq!((r.width, r.height), (200, 200)); + let png = r.to_png(); + assert_eq!(&png[1..4], b"PNG"); +} + +#[test] +fn arrowheads_render() { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let mut n = + Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = render(&d); + // Arrowhead is wider than the 2 pt line. + assert!(r.pixel(73, 47)[0] < 128); +} + +use drawcraft_doc::Arrowhead; +use drawcraft_geom::Point; diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml new file mode 100644 index 000000000..18c86afb1 --- /dev/null +++ b/crates/svg/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "drawcraft-svg" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft SVG import and export" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +kurbo = { workspace = true } +usvg = { workspace = true } +thiserror = { workspace = true } +image = { workspace = true } +# Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). +svgtypes = "0.16" + +[dev-dependencies] +image = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs new file mode 100644 index 000000000..09daaa2ca --- /dev/null +++ b/crates/svg/src/export.rs @@ -0,0 +1,680 @@ +//! The SVG writer. + +use std::collections::{HashMap, HashSet}; + +use drawcraft_color::{BlendMode, GradientKind, GradientPaint, Paint}; +use drawcraft_doc::{AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject}; +use drawcraft_doc::{CharStyle, Justify}; +use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; + +use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape}; + +type Props = Vec<(&'static str, String)>; + +pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { + let rect = opts + .artboard + .and_then(|i| doc.artboards.get(i)) + .map(|a| a.rect) + .or_else(|| doc.art_bounds()) + .or_else(|| doc.artboards.first().map(|a| a.rect)) + .unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)); + let mut w = Writer { + doc, + opts, + xf: Affine::translate((-rect.x0, -rect.y0)), + body: String::new(), + defs: String::new(), + classes: Vec::new(), + used_ids: HashSet::new(), + names: HashMap::new(), + depth: 1, + }; + w.assign_name_ids(); + for l in &doc.layers { + w.node(l); + } + + let nl = if opts.minify { "" } else { "\n" }; + let mut out = String::new(); + if !opts.minify { + out.push_str("\n"); + } + let (ww, hh) = (w.num(rect.width()), w.num(rect.height())); + out.push_str("{nl}")); + if !w.defs.is_empty() || !w.classes.is_empty() { + out.push_str(&w.indent(1)); + out.push_str(&format!("{nl}")); + if !w.classes.is_empty() { + out.push_str(&w.indent(2)); + out.push_str("{nl}", w.indent(2))); + } + out.push_str(&w.defs); + out.push_str(&w.indent(1)); + out.push_str(&format!("{nl}")); + } + if !doc.title.is_empty() { + out.push_str(&w.indent(1)); + out.push_str(&format!("{}{nl}", xml_escape(&doc.title))); + } + out.push_str(&w.body); + out.push_str(""); + out.push_str(nl); + out +} + +struct Writer<'a> { + doc: &'a Document, + opts: &'a ExportOptions, + /// Document → SVG user space (artboard origin translation, symbol instance transforms). + xf: Affine, + body: String, + defs: String, + /// Unique CSS declaration blocks; class name is `cls-{index + 1}`. + classes: Vec, + used_ids: HashSet, + names: HashMap, + depth: usize, +} + +pub(crate) fn blend_css(b: BlendMode) -> &'static str { + match b { + BlendMode::Normal => "normal", + BlendMode::Darken => "darken", + BlendMode::Multiply => "multiply", + BlendMode::ColorBurn => "color-burn", + BlendMode::Lighten => "lighten", + BlendMode::Screen => "screen", + BlendMode::ColorDodge => "color-dodge", + BlendMode::Overlay => "overlay", + BlendMode::SoftLight => "soft-light", + BlendMode::HardLight => "hard-light", + BlendMode::Difference => "difference", + BlendMode::Exclusion => "exclusion", + BlendMode::Hue => "hue", + BlendMode::Saturation => "saturation", + BlendMode::Color => "color", + BlendMode::Luminosity => "luminosity", + } +} + +/// Turn an object name into a valid, readable XML id. +fn sanitize_id(name: &str) -> String { + let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { + s.insert(0, '_'); + } + s +} + +impl Writer<'_> { + fn num(&self, v: f64) -> String { + fmt_num(v, self.opts.decimals) + } + fn indent(&self, depth: usize) -> String { + if self.opts.minify { String::new() } else { " ".repeat(depth) } + } + fn line(&mut self, s: &str) { + let ind = self.indent(self.depth); + self.body.push_str(&ind); + self.body.push_str(s); + if !self.opts.minify { + self.body.push('\n'); + } + } + fn def(&mut self, depth: usize, s: &str) { + let ind = self.indent(depth + 1); + self.defs.push_str(&ind); + self.defs.push_str(s); + if !self.opts.minify { + self.defs.push('\n'); + } + } + fn fresh_id(&mut self, prefix: &str) -> String { + let mut i = 1; + loop { + let id = format!("{prefix}-{i}"); + if self.used_ids.insert(id.clone()) { + return id; + } + i += 1; + } + } + /// Reserve ids for every named object up front so generated def ids never collide with them. + fn assign_name_ids(&mut self) { + if !self.opts.object_ids { + return; + } + let mut named: Vec<(NodeId, String)> = Vec::new(); + self.doc.walk(|n| { + if let Some(name) = &n.name { + named.push((n.id, sanitize_id(name))); + } + }); + for (id, base) in named { + let mut cand = base.clone(); + let mut i = 2; + while !self.used_ids.insert(cand.clone()) { + cand = format!("{base}-{i}"); + i += 1; + } + self.names.insert(id, cand); + } + } + fn id_attr(&self, n: &Node) -> String { + self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default() + } + fn matrix(&self, m: Affine) -> String { + let c = m.as_coeffs(); + format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) + } + + /// Style properties as attributes, an inline style, or a CSS class, per the styling option. + fn attrs(&mut self, props: &Props) -> String { + if props.is_empty() { + return String::new(); + } + let css = |ps: &[&(&str, String)]| ps.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(";"); + match self.opts.styling { + Styling::PresentationAttributes => { + // CSS-only properties go in a style attribute. + let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation")); + let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); + if !style.is_empty() { + s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style)))); + } + s + } + Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::>()))), + Styling::InternalCss => { + let decl = css(&props.iter().collect::>()); + let i = match self.classes.iter().position(|c| *c == decl) { + Some(i) => i, + None => { + self.classes.push(decl); + self.classes.len() - 1 + } + }; + format!(" class=\"cls-{}\"", i + 1) + } + } + } + + fn node_props(n: &Node) -> Props { + let mut p = Props::new(); + if n.opacity < 1.0 { + p.push(("opacity", fmt_num(n.opacity as f64, 3))); + } + if n.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(n.blend).into())); + } + if n.isolate { + p.push(("isolation", "isolate".into())); + } + p + } + + fn path_d(&self, p: &PathData, m: Affine) -> String { + let mut s = String::new(); + let pt = |p: Point| { + let p = m * p; + format!("{} {}", self.num(p.x), self.num(p.y)) + }; + for sp in &p.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + if !s.is_empty() { + s.push(' '); + } + s.push('M'); + s.push_str(&pt(sp.anchors[0].p)); + for i in 0..sp.segment_count() { + let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); + if sp.segment_is_line(i) { + if sp.closed && i == n - 1 { + break; + } + s.push_str(&format!(" L{}", pt(b.p))); + } else { + s.push_str(&format!(" C{} {} {}", pt(a.h_out), pt(b.h_in), pt(b.p))); + } + } + if sp.closed { + s.push_str(" Z"); + } + } + s + } + + fn paint(&mut self, p: &Paint, bounds: Option) -> String { + match p { + Paint::None | Paint::Pattern { .. } => "none".into(), + Paint::Solid { color, .. } => color.to_hex(), + Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), + } + } + + fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { + let mut geom = g.resolve(bounds); + geom.transform(self.xf); + let (s, e) = (geom.start, geom.end); + let head = match g.gradient.kind { + GradientKind::Radial => { + let id = self.fresh_id("radial-gradient"); + let r = (e - s).hypot(); + if (geom.aspect - 1.0).abs() < 1e-9 { + ( + id.clone(), + format!( + "", + self.num(s.x), + self.num(s.y), + self.num(r) + ), + "radialGradient", + ) + } else { + let v = e - s; + let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); + ( + id.clone(), + format!( + "", + self.num(r), + self.matrix(m) + ), + "radialGradient", + ) + } + } + GradientKind::Linear | GradientKind::Freeform => { + let id = self.fresh_id("linear-gradient"); + ( + id.clone(), + format!( + "", + self.num(s.x), + self.num(s.y), + self.num(e.x), + self.num(e.y) + ), + "linearGradient", + ) + } + }; + let (id, open, tag) = head; + self.def(1, &open); + for (off, c, o) in g.gradient.expanded_stops() { + let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; + self.def(2, &format!("", fmt_num(off as f64, 4), c.to_hex())); + } + self.def(1, &format!("")); + id + } + + fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { + let paint = self.paint(&f.paint, bounds); + p.push(("fill", paint)); + if f.opacity < 1.0 { + p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); + } + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + + fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { + let paint = self.paint(&s.paint, bounds); + p.push(("stroke", paint)); + if s.opacity < 1.0 { + p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); + } + if (width - 1.0).abs() > 1e-9 { + p.push(("stroke-width", self.num(width))); + } + match s.cap { + LineCap::Butt => {} + LineCap::Round => p.push(("stroke-linecap", "round".into())), + LineCap::Square => p.push(("stroke-linecap", "square".into())), + } + match s.join { + LineJoin::Miter => { + if (s.miter_limit - 4.0).abs() > 1e-9 { + p.push(("stroke-miterlimit", self.num(s.miter_limit))); + } + } + LineJoin::Round => p.push(("stroke-linejoin", "round".into())), + LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), + } + if let Some(d) = &s.dash + && !d.pattern.is_empty() + && d.pattern.iter().any(|v| *v > 0.0) + { + p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); + if d.offset != 0.0 { + p.push(("stroke-dashoffset", self.num(d.offset))); + } + } + } + + /// Paint a shape (`d`) with a node's appearance stack. + fn shape(&mut self, n: &Node, d: &str, rule: FillRule, bounds: Option) { + let id = self.id_attr(n); + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }) + .collect(); + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.len() - fills; + let simple = fills <= 1 + && strokes <= 1 + && items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.blend == BlendMode::Normal, + AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center, + }); + if simple { + let mut p = Props::new(); + match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { + Some(f) => self.fill_props(f, rule, bounds, &mut p), + None => { + p.push(("fill", "none".into())); + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + } + if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { + self.stroke_props(s, s.width, bounds, &mut p); + if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { + p.push(("paint-order", "stroke".into())); + } + } + p.extend(Self::node_props(n)); + let a = self.attrs(&p); + self.line(&format!("")); + return; + } + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + for it in items { + let mut p = Props::new(); + match it { + AppearanceItem::Fill(f) => { + self.fill_props(f, rule, bounds, &mut p); + if f.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(f.blend).into())); + } + let a = self.attrs(&p); + self.line(&format!("")); + } + AppearanceItem::Stroke(s) => { + p.push(("fill", "none".into())); + let width = if s.align == StrokeAlign::Center { s.width } else { s.width * 2.0 }; + self.stroke_props(s, width, bounds, &mut p); + if s.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(s.blend).into())); + } + let rule_attr = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + let extra = match s.align { + StrokeAlign::Center => String::new(), + StrokeAlign::Inside => { + let cid = self.fresh_id("clip-path"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + StrokeAlign::Outside => { + let mid = self.fresh_id("mask"); + let b = self + .xf + .transform_rect_bbox(bounds.unwrap_or_default()) + .inflate(width * s.miter_limit.max(1.0) + 1.0, width * s.miter_limit.max(1.0) + 1.0); + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + self.def( + 1, + &format!(""), + ); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" mask=\"url(#{mid})\"") + } + }; + let a = self.attrs(&p); + self.line(&format!("")); + } + } + } + self.depth -= 1; + self.line(""); + } + + /// `(d, rule)` of every path in a clipping object. + fn clip_shapes(&self, n: &Node, out: &mut Vec<(String, FillRule)>) { + match &n.kind { + NodeKind::Path { path, rule, .. } => out.push((self.path_d(path, self.xf), *rule)), + NodeKind::Compound { children, rule } => { + let d: Vec = children.iter().filter_map(|c| c.path_data()).map(|p| self.path_d(p, self.xf)).collect(); + out.push((d.join(" "), *rule)); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + self.clip_shapes(c, out); + } + } + NodeKind::Image(im) => { + let r = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); + let p = drawcraft_geom::shapes::rectangle(r).transformed(im.xf); + out.push((self.path_d(&p, self.xf), FillRule::NonZero)); + } + NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {} + } + } + + fn node(&mut self, n: &Node) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Layer { template: true, .. } => {} + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + for c in children { + self.node(c); + } + self.depth -= 1; + self.line(""); + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + let mut shapes = Vec::new(); + self.clip_shapes(clip, &mut shapes); + let cid = self.fresh_id("clip-path"); + self.def(1, &format!("")); + for (d, rule) in shapes { + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(2, &format!("")); + } + self.def(1, ""); + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + for c in rest { + self.node(c); + } + self.depth -= 1; + self.line(""); + } + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { path, rule, .. } => { + if path.is_empty() { + return; + } + let d = self.path_d(path, self.xf); + self.shape(n, &d, *rule, n.geometric_bounds()); + } + NodeKind::Compound { children, rule } => { + let d: Vec = children + .iter() + .filter(|c| c.visible) + .filter_map(|c| c.path_data()) + .filter(|p| !p.is_empty()) + .map(|p| self.path_d(p, self.xf)) + .collect(); + if d.is_empty() { + return; + } + self.shape(n, &d.join(" "), *rule, n.geometric_bounds()); + } + NodeKind::Text(t) => self.text(n, t), + NodeKind::Image(im) => { + let href = match self.doc.images.get(&im.key) { + Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)), + _ => match &im.link { + Some(l) => l.clone(), + None => return, + }, + }; + let id = self.id_attr(n); + let m = self.matrix(self.xf * im.xf); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!( + "", + im.width, + im.height, + xml_escape(&href) + )); + } + NodeKind::SymbolInstance { symbol, xf } => { + let Some(sym) = self.doc.symbols.iter().find(|s| s.name == *symbol) else { return }; + let saved = self.xf; + self.xf = saved * *xf; + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + self.node(&sym.art.clone()); + self.depth -= 1; + self.line(""); + self.xf = saved; + } + } + } + + fn char_props(&mut self, st: &CharStyle) -> Props { + let mut p = Props::new(); + let fam = + if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; + p.push(("font-family", fam)); + p.push(("font-size", self.num(st.size))); + let fs = st.font_style.to_ascii_lowercase(); + if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { + p.push(("font-weight", "bold".into())); + } + if fs.contains("italic") || fs.contains("oblique") { + p.push(("font-style", "italic".into())); + } + let fill = match &st.fill { + Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()), + p => self.paint(p, None), + }; + p.push(("fill", fill)); + if !st.stroke.is_none() && st.stroke_width > 0.0 { + let s = match &st.stroke { + Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()), + p => self.paint(p, None), + }; + p.push(("stroke", s)); + p.push(("stroke-width", self.num(st.stroke_width))); + } + if st.tracking != 0.0 { + p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size))); + } + match (st.underline, st.strikethrough) { + (true, true) => p.push(("text-decoration", "underline line-through".into())), + (true, false) => p.push(("text-decoration", "underline".into())), + (false, true) => p.push(("text-decoration", "line-through".into())), + _ => {} + } + p + } + + fn text(&mut self, n: &Node, t: &TextObject) { + let first = t.first_style(); + let lead = first.effective_leading(); + let id = self.id_attr(n); + let (m, on_path) = match &t.kind { + TextKind::Point => (self.xf * t.xf, None), + TextKind::Area { frame } => { + let b = frame.bounds().unwrap_or_default(); + (self.xf * t.xf * Affine::translate(Vec2::new(b.x0 + t.para.left_indent, b.y0 + first.size)), None) + } + TextKind::OnPath { path, start } => { + let pid = self.fresh_id("text-path"); + let d = self.path_d(path, self.xf * t.xf); + self.def(1, &format!("")); + (Affine::IDENTITY, Some((pid, *start))) + } + }; + let mut props = self.char_props(&first); + match t.para.justify { + Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), + Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), + _ => {} + } + props.extend(Self::node_props(n)); + let a = self.attrs(&props); + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + let mut s = format!(""); + if let Some((pid, start)) = &on_path { + s.push_str(&format!("", fmt_num(start * 100.0, 3))); + } + let base = self.char_props(&first); + let mut line = 0usize; + let mut pending = false; + for run in &t.runs { + let rp = self.char_props(&run.style); + let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect(); + for (i, piece) in run.text.split('\n').enumerate() { + if i > 0 { + line += 1; + pending = true; + } + if piece.is_empty() { + continue; + } + let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() }; + pending = false; + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_escape(piece))); + } + } + if on_path.is_some() { + s.push_str(""); + } + s.push_str(""); + self.line(&s); + } +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs new file mode 100644 index 000000000..020d0f025 --- /dev/null +++ b/crates/svg/src/import.rs @@ -0,0 +1,698 @@ +//! usvg tree → document conversion. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use drawcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use drawcraft_doc::{ + Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, + StrokeLayer, TextKind, TextObject, TextRun, +}; +use drawcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2}; +use usvg::roxmltree; + +use crate::SvgError; + +pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; + let size = tree.size(); + let doc = Document::new(size.width() as f64, size.height() as f64); + let mut im = Importer { doc, warnings: Vec::new() }; + + // usvg wraps everything in an id-less group carrying the viewBox transform when needed. + let mut top = tree.root(); + let mut base = aff(top.transform()); + if let [usvg::Node::Group(g)] = top.children() + && g.id().is_empty() + && is_plain(g) + { + base *= aff(g.transform()); + top = g; + } + + // Top-level `` elements become layers (Illustrator does the same); otherwise all art goes + // into "Layer 1". + let layer_mode = + !top.children().is_empty() && top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && is_plain(g))); + if layer_mode { + im.doc.layers.clear(); + for (i, c) in top.children().iter().enumerate() { + let usvg::Node::Group(g) = c else { continue }; + let children = im.children(g, base * aff(g.transform())); + let id = im.doc.alloc_id(); + let mut l = Node::layer(id, g.id(), LayerColor::Preset((i % drawcraft_doc::LAYER_COLORS.len()) as u8)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + im.doc.layers.push(Arc::new(l)); + } + } else { + let children = im.children(top, base); + if let Some(l) = im.doc.layers.first_mut() + && let Some(ch) = Arc::make_mut(l).children_mut() + { + *ch = children; + } + } + + text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, layer_mode); + Ok((im.doc, im.warnings)) +} + +struct Importer { + doc: Document, + warnings: Vec, +} + +fn aff(t: usvg::Transform) -> Affine { + Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) +} + +/// Linear part of `m` applied to `v`. +fn lin(m: Affine, v: Vec2) -> Vec2 { + (m * v.to_point()) - (m * Point::ZERO) +} + +fn is_plain(g: &usvg::Group) -> bool { + g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal +} + +fn blend(b: usvg::BlendMode) -> BlendMode { + use usvg::BlendMode as B; + match b { + B::Normal => BlendMode::Normal, + B::Multiply => BlendMode::Multiply, + B::Screen => BlendMode::Screen, + B::Overlay => BlendMode::Overlay, + B::Darken => BlendMode::Darken, + B::Lighten => BlendMode::Lighten, + B::ColorDodge => BlendMode::ColorDodge, + B::ColorBurn => BlendMode::ColorBurn, + B::HardLight => BlendMode::HardLight, + B::SoftLight => BlendMode::SoftLight, + B::Difference => BlendMode::Difference, + B::Exclusion => BlendMode::Exclusion, + B::Hue => BlendMode::Hue, + B::Saturation => BlendMode::Saturation, + B::Color => BlendMode::Color, + B::Luminosity => BlendMode::Luminosity, + } +} + +fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath { + use usvg::tiny_skia_path::PathSegment as S; + let pt = |p: usvg::tiny_skia_path::Point| m * Point::new(p.x as f64, p.y as f64); + let mut bp = BezPath::new(); + for s in p.segments() { + match s { + S::MoveTo(p) => bp.move_to(pt(p)), + S::LineTo(p) => bp.line_to(pt(p)), + S::QuadTo(a, p) => bp.quad_to(pt(a), pt(p)), + S::CubicTo(a, b, p) => bp.curve_to(pt(a), pt(b), pt(p)), + S::Close => bp.close_path(), + } + } + bp +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +fn rule(r: usvg::FillRule) -> FillRule { + match r { + usvg::FillRule::NonZero => FillRule::NonZero, + usvg::FillRule::EvenOdd => FillRule::EvenOdd, + } +} + +impl Importer { + fn warn(&mut self, s: String) { + if !self.warnings.contains(&s) { + self.warnings.push(s); + } + } + + fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec> { + g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect() + } + + fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option { + match n { + usvg::Node::Group(g) => self.group(g, acc), + usvg::Node::Path(p) => self.path(p, acc), + usvg::Node::Image(i) => self.image(i, acc), + // Only present when usvg had fonts; text is read from the XML instead. + usvg::Node::Text(_) => None, + } + } + + fn named(&mut self, id: &str, kind: NodeKind) -> Node { + let nid = self.doc.alloc_id(); + let mut n = Node::new(nid, kind); + if !id.is_empty() { + n.name = Some(id.to_string()); + } + n + } + + fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let ts = acc * aff(g.transform()); + let label = if g.id().is_empty() { "a group".to_string() } else { format!("'{}'", g.id()) }; + if g.mask().is_some() { + self.warn(format!("mask on {label} ignored")); + } + if !g.filters().is_empty() { + self.warn(format!("filter on {label} ignored")); + } + let children = self.children(g, ts); + let mut n = if let Some(cp) = g.clip_path() { + if cp.clip_path().is_some() { + self.warn(format!("nested clip path on {label} approximated by its outer clip")); + } + let clip = self.clip_node(cp, ts)?; + let mut ch = vec![Arc::new(clip)]; + ch.extend(children); + self.named(g.id(), NodeKind::Group { children: ch, clip: true }) + } else { + if children.is_empty() { + return None; + } + // An id-less wrapper around a single object (usvg adds these for opacity/transform on + // shapes): fold its opacity and blend into the object. + if g.id().is_empty() && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) { + let mut c = Arc::unwrap_or_clone(children.into_iter().next().unwrap()); + c.opacity *= g.opacity().get(); + if g.blend_mode() != usvg::BlendMode::Normal { + c.blend = blend(g.blend_mode()); + } + c.isolate |= g.isolate(); + return Some(c); + } + self.named(g.id(), NodeKind::Group { children, clip: false }) + }; + n.opacity = g.opacity().get(); + n.blend = blend(g.blend_mode()); + n.isolate = g.isolate(); + Some(n) + } + + fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { + fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { + for c in g.children() { + match c { + usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), + usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + _ => {} + } + } + } + let mut shapes = Vec::new(); + collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + if shapes.is_empty() { + return None; + } + let r = shapes[0].1; + let mut subs = Vec::new(); + for (bp, _) in &shapes { + subs.extend(PathData::from_bezpath(bp).subpaths); + } + let id = cp.id().to_string(); + if subs.len() == 1 { + let path = PathData::new(subs); + return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false })); + } + let children = subs + .into_iter() + .map(|sp| { + let nid = self.doc.alloc_id(); + Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false })) + }) + .collect(); + Some(self.named(&id, NodeKind::Compound { children, rule: r })) + } + + fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { + let stops = |g: &usvg::BaseGradient| { + g.stops() + .iter() + .map(|s| { + let c = s.color(); + GradientStop { offset: s.offset().get(), color: Color::rgb8(c.red, c.green, c.blue), opacity: s.opacity().get(), midpoint: 0.5 } + }) + .collect::>() + }; + let gp = |kind, stops, geom: GradientGeom| { + Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, geom: Some(geom), angle: geom.angle_deg(), swatch: None })) + }; + match p { + usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + usvg::Paint::LinearGradient(lg) => { + if lg.spread_method() != usvg::SpreadMethod::Pad { + self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); + } + let t = m * aff(lg.transform()); + let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64)); + let start = t * p1; + let far = t * p2; + // Isolines (perpendicular to the vector in gradient space) stay parallel under an + // affine map; the document gradient vector is the normal to their image. + let dir = p2 - p1; + let iso = lin(t, Vec2::new(-dir.y, dir.x)); + let nrm = Vec2::new(-iso.y, iso.x); + let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far }; + gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 }) + } + usvg::Paint::RadialGradient(rg) => { + if rg.spread_method() != usvg::SpreadMethod::Pad { + self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id())); + } + if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 { + self.warn(format!("gradient '{}': focal point ignored", rg.id())); + } + let t = m * aff(rg.transform()); + let r = rg.r().get() as f64; + let c = Point::new(rg.cx() as f64, rg.cy() as f64); + let vx = lin(t, Vec2::new(r, 0.0)); + let vy = lin(t, Vec2::new(0.0, r)); + let start = t * c; + let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 }; + gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect }) + } + usvg::Paint::Pattern(pt) => { + self.warn(format!("pattern '{}' not supported; painted as none", pt.id())); + Paint::None + } + } + } + + fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { + let mut fill = None; + let mut r = FillRule::NonZero; + if let Some(f) = p.fill() { + let paint = self.paint(f.paint(), m); + let mut fl = FillLayer::new(paint); + fl.opacity = f.opacity().get(); + r = rule(f.rule()); + fill = Some(AppearanceItem::Fill(fl)); + } + let mut stroke = None; + if let Some(s) = p.stroke() { + let scale = m.determinant().abs().sqrt(); + let paint = self.paint(s.paint(), m); + let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); + sl.opacity = s.opacity().get(); + sl.cap = match s.linecap() { + usvg::LineCap::Butt => LineCap::Butt, + usvg::LineCap::Round => LineCap::Round, + usvg::LineCap::Square => LineCap::Square, + }; + sl.join = match s.linejoin() { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter, + usvg::LineJoin::Round => LineJoin::Round, + usvg::LineJoin::Bevel => LineJoin::Bevel, + }; + sl.miter_limit = s.miterlimit().get() as f64; + if let Some(d) = s.dasharray() { + sl.dash = Some(Dash { + pattern: d.iter().map(|v| *v as f64 * scale).collect(), + offset: s.dashoffset() as f64 * scale, + align_corners: false, + }); + } + stroke = Some(AppearanceItem::Stroke(sl)); + } + let items = match p.paint_order() { + usvg::PaintOrder::FillAndStroke => [fill, stroke], + usvg::PaintOrder::StrokeAndFill => [stroke, fill], + }; + (Appearance { items: items.into_iter().flatten().collect(), effects: vec![] }, r) + } + + fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option { + if !p.is_visible() { + return None; + } + let path = PathData::from_bezpath(&bezpath(p.data(), acc)); + if path.is_empty() { + return None; + } + let (appearance, r) = self.appearance(p, acc); + let mut n = if path.subpaths.len() > 1 { + // Multi-subpath SVG paths are compound paths (as in Illustrator). + let children = path + .subpaths + .into_iter() + .map(|sp| { + let id = self.doc.alloc_id(); + let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }); + c.appearance = appearance.clone(); + Arc::new(c) + }) + .collect(); + self.named(p.id(), NodeKind::Compound { children, rule: r }) + } else { + self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false }) + }; + n.appearance = appearance; + Some(n) + } + + fn image(&mut self, i: &usvg::Image, acc: Affine) -> Option { + if !i.is_visible() { + return None; + } + let (bytes, mime) = match i.kind() { + usvg::ImageKind::JPEG(d) => (d, "image/jpeg"), + usvg::ImageKind::PNG(d) => (d, "image/png"), + usvg::ImageKind::GIF(d) => (d, "image/gif"), + usvg::ImageKind::WEBP(d) => (d, "image/webp"), + usvg::ImageKind::SVG(_) => { + self.warn("embedded SVG image skipped".into()); + return None; + } + }; + let size = i.size(); + let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..])) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); + let key = format!("img-{:016x}", fnv1a(bytes)); + self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob { mime: mime.into(), bytes: Arc::new(bytes.to_vec()) }); + let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); + Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None }))) + } +} + +// --------------------------------------------------------------------------------------------- +// fallback: read text elements straight from the XML as live point type. + +type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; + +struct TextCtx { + /// `.class` → declarations from ` + Hello World + "#, + ); + let a = art(&d); + let NodeKind::Text(t) = &a[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "Hello World"); + assert_eq!(t.runs.len(), 2); + assert_eq!(t.runs[0].style.font_family, "Helvetica Neue"); + assert!((t.runs[1].style.size - 30.0).abs() < 1e-6); + assert_eq!(t.runs[1].style.font_style, "Bold"); + assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#0000ff"); +} + +#[test] +fn import_invalid_is_error() { + assert!(import("not svg").is_err()); + assert!(import("")).collect::>().join("\n"); + assert_eq!(strip(&s1b), strip(&s2)); + let _ = PathData::default(); +} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..a18729db7 --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "drawcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft test helpers" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-doc = { workspace = true } diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..ef6f1b1c1 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,2 @@ +//! DrawCraft test helpers +#![forbid(unsafe_code)] diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 000000000..25544d227 --- /dev/null +++ b/crates/text/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "drawcraft-text" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft text: font database, shaping, layout, glyph outlines" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-doc = { workspace = true } +kurbo = { workspace = true } +skrifa = { workspace = true } +harfrust = { workspace = true } +log = { workspace = true } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs new file mode 100644 index 000000000..4155cd3ea --- /dev/null +++ b/crates/text/src/fontdb.rs @@ -0,0 +1,407 @@ +//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use kurbo::BezPath; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; +use skrifa::string::StringId; +use skrifa::{GlyphId, MetadataProvider}; + +/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). +pub const FALLBACK_FAMILY: &str = "Source Sans 3"; + +static BUNDLED: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"), + include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), + include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), + include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), +]; + +enum FontBytes { + Static(&'static [u8]), + Owned(Arc>), +} + +/// One loaded font face. +pub struct FontFace { + id: u32, + /// Typographic family name (e.g. "Source Sans 3"). + pub family: String, + /// Typographic style name (e.g. "Semibold", "Italic"). + pub style: String, + /// usWeightClass-style weight (400 = regular). + pub weight: f32, + pub italic: bool, + bytes: FontBytes, + index: u32, + pub(crate) upem: f64, + /// Ascender in font units (positive = up). + pub(crate) ascent: f64, + /// Descender in font units (positive = down). + pub(crate) descent: f64, + pub(crate) shaper: harfrust::ShaperData, +} + +impl std::fmt::Debug for FontFace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FontFace({} {})", self.family, self.style) + } +} + +impl FontFace { + pub(crate) fn data(&self) -> &[u8] { + match &self.bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + } + } + pub(crate) fn skrifa(&self) -> Option> { + skrifa::FontRef::from_index(self.data(), self.index).ok() + } + pub(crate) fn hb(&self) -> Option> { + harfrust::FontRef::from_index(self.data(), self.index).ok() + } + /// Unique id of this face within the process. + pub fn id(&self) -> u32 { + self.id + } + /// Does the face map `c` to a glyph? + pub fn covers(&self, c: char) -> bool { + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +struct CatalogEntry { + family: String, + style: String, + path: std::path::PathBuf, +} + +/// Process-wide font database. +pub struct FontDb { + faces: RwLock>>, + outlines: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock>, +} + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +const OUTLINE_CACHE_MAX: usize = 50_000; + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. +fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => 0, + }; + (0..count) + .filter_map(|i| { + let f = skrifa::FontRef::from_index(data, i).ok()?; + let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((i, family, style)) + }) + .collect() +} + +fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Option { + let data: &[u8] = match &bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + }; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(Size::unscaled(), LocationRef::default()); + let a = f.attributes(); + let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?); + Some(FontFace { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + family, + style, + weight: a.weight.value(), + italic: !matches!(a.style, skrifa::attribute::Style::Normal), + upem: m.units_per_em.max(1) as f64, + ascent: m.ascent as f64, + descent: -(m.descent as f64), + shaper, + bytes, + index, + }) +} + +fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() +} + +/// Weight implied by a style name. +fn style_weight(style: &str) -> f32 { + let s = norm(style); + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +impl FontDb { + fn new_bundled() -> Self { + let mut faces = Vec::new(); + for data in BUNDLED { + for (i, family, style) in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), i, family, style) { + faces.push(Arc::new(f)); + } + } + } + Self { + faces: RwLock::new(faces), + outlines: Mutex::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock::new(Vec::new()), + } + } + + /// Process-wide database preloaded with the bundled fonts. + pub fn global() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(FontDb::new_bundled) + } + + fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { + self.faces.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + let mut v: Vec<(bool, f32, String)> = + self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() { + if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|t| t.2).collect() + } + + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or + /// every face was already present). + pub fn add_font(&self, bytes: Vec) -> usize { + let data = Arc::new(bytes); + let mut added = 0; + for (i, family, style) in enumerate_faces(&data) { + if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style) { + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + added += 1; + } + } + added + } + + /// Scan the platform's font directories and catalog the faces found (native only; not called by + /// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the + /// number of faces cataloged. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_system_fonts(&self) -> usize { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + let mut found = Vec::new(); + let mut stack = dirs; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + for (_, family, style) in enumerate_faces(&data) { + found.push(CatalogEntry { family, style, path: p.clone() }); + } + } + } + let n = found.len(); + log::debug!("cataloged {n} system font faces"); + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + n + } + + #[cfg(not(target_arch = "wasm32"))] + fn load_cataloged(&self, family: &str) -> bool { + let paths: Vec = { + let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner()); + let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + p.dedup(); + p + }; + let mut any = false; + for p in paths { + if let Ok(data) = std::fs::read(&p) { + any |= self.add_font(data) > 0; + } + } + any + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// Source Sans 3 Regular. + pub fn face(&self, family: &str, style: &str) -> Arc { + if let Some(f) = self.find(family, style) { + return f; + } + #[cfg(not(target_arch = "wasm32"))] + if self.load_cataloged(family) + && let Some(f) = self.find(family, style) + { + return f; + } + self.find(FALLBACK_FAMILY, style) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + .expect("bundled fonts are always present") + } + + /// Is `family` available (loaded)? + pub fn has_family(&self, family: &str) -> bool { + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + } + + fn find(&self, family: &str, style: &str) -> Option> { + let faces = self.read_faces(); + let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + return None; + } + let ns = norm(style); + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// First face (fallback family first, then load order) that covers `c`. + pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option> { + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + pub(crate) fn outline(&self, face: &FontFace, gid: u32) -> Arc { + let key = (face.id, gid); + if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return p.clone(); + } + let mut pen = FlipPen(BezPath::new()); + if let Some(f) = face.skrifa() + && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) + { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen); + } + let p = Arc::new(pen.0); + let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } +} + +struct FlipPen(BezPath); + +impl OutlinePen for FlipPen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, -y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, -y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 000000000..771251f49 --- /dev/null +++ b/crates/text/src/layout.rs @@ -0,0 +1,415 @@ +//! Line breaking and glyph placement for point, area and on-path type. + +use std::ops::Range; + +use drawcraft_doc::{CharStyle, Justify, ParaStyle, TextKind, TextObject}; +use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2}; + +use crate::fontdb::FontDb; +use crate::shape::{SGlyph, shape_range, style_metrics}; +use crate::{LineInfo, PositionedGlyph, TextLayout}; + +const EPS: f64 = 1e-6; + +struct Ctx<'a> { + db: &'a FontDb, + text: &'a str, + runs: Vec<(Range, &'a CharStyle)>, + default: CharStyle, + out: TextLayout, +} + +impl Ctx<'_> { + fn style_at(&self, b: usize) -> &CharStyle { + let find = |b: usize| self.runs.iter().find(|(r, _)| r.start <= b && b < r.end).map(|(_, s)| *s); + find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) + } + + fn shape_para(&self, r: Range) -> Vec { + let mut v = Vec::with_capacity(r.len()); + shape_range(self.db, self.text, r, &self.runs, &mut v); + v + } + + fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + let src = self.db.outline(&g.face, g.gid); + let outline = if src.elements().is_empty() { + BezPath::new() + } else { + let mut p = (*src).clone(); + p.apply_affine(pre * local); + p + }; + self.out.glyphs.push(PositionedGlyph { outline, run: g.run, byte: g.byte, origin, advance, len: g.len, angle, line, font_id: g.face.id() }); + } +} + +/// Lay out a text object into text-space glyph outlines. +pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { + let text = t.plain_text(); + let mut runs = Vec::with_capacity(t.runs.len()); + let mut off = 0; + for r in &t.runs { + runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } + let mut paras = Vec::new(); + let mut s = 0; + for (i, c) in text.char_indices() { + if c == '\n' { + paras.push(s..i); + s = i + 1; + } + } + paras.push(s..text.len()); + let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), out: TextLayout::default() }; + match &t.kind { + TextKind::Point => flow(&mut cx, ¶s, &t.para, None), + TextKind::Area { frame } => { + let region = Region::new(&frame.to_bezpath()); + flow(&mut cx, ¶s, &t.para, Some(®ion)); + } + TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed()), + } + finish_bounds(&mut cx.out); + cx.out +} + +fn finish_bounds(out: &mut TextLayout) { + let mut b: Option = None; + let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r))); + for g in &out.glyphs { + if !g.outline.elements().is_empty() { + add(g.outline.bounding_box()); + } + } + if !out.on_path { + for l in &out.lines { + add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)); + } + } + out.bounds = b.unwrap_or_default(); +} + +/// A flattened area-type frame. +struct Region { + bbox: Rect, + polys: Vec>, +} + +impl Region { + fn new(path: &BezPath) -> Self { + let mut polys: Vec> = Vec::new(); + kurbo::flatten(path, 0.1, |el| match el { + PathEl::MoveTo(p) => polys.push(vec![p]), + PathEl::LineTo(p) => { + if let Some(v) = polys.last_mut() { + v.push(p); + } + } + _ => {} + }); + polys.retain(|p| p.len() >= 3); + Self { bbox: path.bounding_box(), polys } + } + + /// Inside intervals (even-odd) of the horizontal line at `y`. + fn intervals(&self, y: f64) -> Vec<(f64, f64)> { + let mut xs = Vec::new(); + for poly in &self.polys { + for i in 0..poly.len() { + let a = poly[i]; + let b = poly[(i + 1) % poly.len()]; + if (a.y <= y) != (b.y <= y) { + xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); + } + } + } + xs.sort_by(f64::total_cmp); + xs.chunks_exact(2).map(|c| (c[0], c[1])).collect() + } + + /// Widest horizontal span inside the frame over the band `top..bottom`. + fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> { + if self.polys.is_empty() { + return (self.bbox.width() > 0.0).then_some((self.bbox.x0, self.bbox.x1)); + } + let clamp = |y: f64| y.clamp(self.bbox.y0 + 1e-4, self.bbox.y1 - 1e-4); + let mid = self.intervals(clamp((top + bottom) * 0.5)); + let others = [self.intervals(clamp(top)), self.intervals(clamp(bottom))]; + mid.into_iter() + .filter_map(|(mut a, mut b)| { + for o in &others { + let best = + o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?; + a = best.0; + b = best.1; + } + (b > a).then_some((a, b)) + }) + .max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) + } +} + +/// Greedy break: returns the end glyph index for a line starting at `i` of the given width. +fn break_line(g: &[SGlyph], i: usize, width: f64) -> usize { + if !width.is_finite() { + return g.len(); + } + let mut x = 0.0; + let mut last_break = None; + let mut j = i; + while j < g.len() { + let gl = &g[j]; + if j > i && !gl.is_space() && x + gl.adv > width + EPS { + break; + } + x += gl.adv; + if gl.break_after() { + last_break = Some(j + 1); + } + j += 1; + } + if j >= g.len() { + return g.len(); + } + // Don't separate a cluster's glyphs. + let mut end = match last_break { + Some(b) if b > i => b, + _ => j, + }; + while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte { + end -= 1; + } + end +} + +fn max_metrics(g: &[SGlyph]) -> Option<(f64, f64, f64)> { + (!g.is_empty()).then(|| g.iter().fold((0.0f64, 0.0f64, 0.0f64), |m, g| (m.0.max(g.ascent), m.1.max(g.descent), m.2.max(g.leading)))) +} + +fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, region: Option<&Region>) { + let mut prev_baseline: Option = None; + let mut pending_space = 0.0; + 'paras: for (pi, pr) in paras.iter().enumerate() { + let sg = cx.shape_para(pr.clone()); + let pm = style_metrics(cx.db, cx.style_at(pr.start)); + if pi > 0 { + pending_space += para.space_before; + } + let n = sg.len(); + let mut i = 0; + let mut first_line = true; + loop { + let est = if i < n { (sg[i].ascent, sg[i].descent, sg[i].leading) } else { pm }; + let first_ind = if first_line { para.first_line_indent } else { 0.0 }; + let ind_l = para.left_indent + first_ind; + let mut baseline = match (prev_baseline, region) { + (None, Some(r)) => r.bbox.y0 + est.0, + (None, None) => 0.0, + (Some(b), _) => b + est.2 + pending_space, + }; + let (x0, x1) = match region { + None => (f64::NEG_INFINITY, f64::INFINITY), + Some(r) => loop { + if baseline + est.1 > r.bbox.y1 + 0.01 { + cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; + break 'paras; + } + match r.span(baseline - est.0, baseline + est.1) { + Some((a, b)) if b - a - ind_l - para.right_indent > est.0.max(1.0) => break (a, b), + _ => baseline += est.2.max(1.0), + } + }, + }; + let (ax0, ax1) = (x0 + ind_l, x1 - para.right_indent); + let width = ax1 - ax0; + let end = if i < n { break_line(&sg, i, width) } else { n }; + let m = max_metrics(&sg[i..end]).unwrap_or(pm); + baseline += if prev_baseline.is_none() { m.0 - est.0 } else { m.2 - est.2 }; + if let Some(r) = region + && baseline + m.1 > r.bbox.y1 + 0.01 + { + cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; + break 'paras; + } + pending_space = 0.0; + let last_of_para = end >= n; + let mut trimmed = end; + while trimmed > i && sg[trimmed - 1].is_space() { + trimmed -= 1; + } + let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum(); + let (align, justify) = match para.justify { + Justify::Left => (0, false), + Justify::Center => (1, false), + Justify::Right => (2, false), + Justify::JustifyLeft => (0, !last_of_para), + Justify::JustifyCenter => (1, !last_of_para), + Justify::JustifyRight => (2, !last_of_para), + Justify::JustifyAll => (0, true), + }; + let justify = justify && region.is_some(); + let (mut per_space, mut per_gap) = (0.0, 0.0); + if justify && width - w > EPS { + let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count(); + if spaces > 0 { + per_space = (width - w) / spaces as f64; + } else if para.justify == Justify::JustifyAll && trimmed - i > 1 { + per_gap = (width - w) / (trimmed - i - 1) as f64; + } + } + let start_x = if justify { + ax0 + } else if region.is_none() { + match align { + 0 => ind_l, + 1 => (ind_l - para.right_indent - w) * 0.5, + _ => -para.right_indent - w, + } + } else { + match align { + 0 => ax0, + 1 => ax0 + (width - w) * 0.5, + _ => ax1 - w, + } + }; + let li = cx.out.lines.len(); + let glyph_start = cx.out.glyphs.len(); + let mut x = start_x; + let mut x_end = start_x; + for (j, g) in sg.iter().enumerate().take(end).skip(i) { + let mut adv = g.adv; + if j < trimmed { + if g.is_space() { + adv += per_space; + } else if j + 1 < trimmed { + adv += per_gap; + } + } + cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li); + x += adv; + if j + 1 == trimmed { + x_end = x; + } + } + cx.out.lines.push(LineInfo { + baseline, + x0: start_x, + x1: x_end, + ascent: m.0, + descent: m.1, + start: if i < n { sg[i].byte } else { pr.start }, + end: if last_of_para { pr.end } else { sg[end].byte }, + glyph_start, + glyph_end: cx.out.glyphs.len(), + }); + prev_baseline = Some(baseline); + first_line = false; + i = end; + if i >= n { + break; + } + } + pending_space += para.space_after; + } +} + +/// Arc-length parameterised path. +struct ArcPath { + segs: Vec<(PathSeg, f64, f64)>, + len: f64, +} + +impl ArcPath { + fn new(p: &BezPath) -> Self { + let mut segs = Vec::new(); + let mut cum = 0.0; + for s in p.segments() { + let l = s.arclen(1e-4); + if l > 1e-9 { + segs.push((s, cum, l)); + cum += l; + } + } + Self { segs, len: cum } + } + + /// Point and unit tangent at arc length `s`. + fn at(&self, s: f64) -> (Point, Vec2) { + let s = s.clamp(0.0, self.len); + let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1); + let (seg, c, l) = self.segs[i]; + let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0); + let p = seg.eval(t); + let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0)); + let d = seg.eval(t1) - seg.eval(t0); + let len = d.hypot(); + (p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) }) + } +} + +fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &BezPath, start: f64, closed: bool) { + cx.out.on_path = true; + let mut sg = Vec::new(); + for pr in paras { + sg.extend(cx.shape_para(pr.clone())); + } + let ap = ArcPath::new(path); + let m = max_metrics(&sg).unwrap_or_else(|| style_metrics(cx.db, cx.style_at(0))); + let text_len = cx.text.len(); + if ap.segs.is_empty() { + cx.out.overflow = !sg.is_empty(); + cx.out.lines.push(LineInfo { + baseline: 0.0, + x0: 0.0, + x1: 0.0, + ascent: m.0, + descent: m.1, + start: 0, + end: text_len, + glyph_start: 0, + glyph_end: 0, + }); + return; + } + let s_start = start.clamp(0.0, 1.0) * ap.len; + let avail = if closed { ap.len } else { ap.len - s_start }; + let w: f64 = sg.iter().map(|g| g.adv).sum(); + let s0 = match para.justify { + Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0), + _ => s_start, + }; + let mut x = 0.0; + for g in &sg { + let s = s0 + x; + if s + g.adv - s_start > avail + 1e-6 { + cx.out.overflow = true; + break; + } + let mut mid = s + g.adv * 0.5; + if closed { + mid = mid.rem_euclid(ap.len); + } + let (p, dir) = ap.at(mid); + let angle = dir.y.atan2(dir.x); + let pre = Affine::translate(p.to_vec2()) * Affine::rotate(angle) * Affine::translate((-g.adv * 0.5, 0.0)); + cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0); + x += g.adv; + } + let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 }); + let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x }); + cx.out.lines.push(LineInfo { + baseline: ps.y, + x0: ps.x, + x1: pe.x, + ascent: m.0, + descent: m.1, + start: 0, + end: text_len, + glyph_start: 0, + glyph_end: cx.out.glyphs.len(), + }); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 000000000..c9aad2be1 --- /dev/null +++ b/crates/text/src/lib.rs @@ -0,0 +1,149 @@ +//! DrawCraft text: font database, shaping, layout, glyph outlines. +//! +//! API contract used by `drawcraft-render`, `drawcraft-tools` and the UI: +//! - [`FontDb::global`]: process-wide database preloaded with the bundled OFL fonts. +//! - [`layout`]: lays out a [`TextObject`] into glyph outlines in *text space* (apply `t.xf` to +//! get document coordinates), plus line and caret information. +//! - [`caret_position`] / [`hit_byte`]: caret geometry and hit testing for the Type tool. +//! +//! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space. +#![forbid(unsafe_code)] + +mod fontdb; +mod layout; +mod shape; + +pub use drawcraft_doc::TextObject; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace}; +use kurbo::{BezPath, Point, Rect, Vec2}; +pub use layout::layout; + +/// One laid-out glyph. +#[derive(Clone, Debug)] +pub struct PositionedGlyph { + /// Outline in text space. + pub outline: BezPath, + /// Index of the run (`TextObject::runs`) the glyph came from. + pub run: usize, + /// Byte offset of the source character (cluster start) in the plain text. + pub byte: usize, + /// Pen position on the baseline (leading edge of the glyph). + pub origin: Point, + /// Advance along the baseline (includes tracking and justification space). + pub advance: f64, + /// Number of source bytes covered by the glyph's cluster. + pub len: usize, + /// Baseline direction in radians (0 = horizontal; non-zero for type on a path). + pub angle: f64, + /// Index into [`TextLayout::lines`]. + pub line: usize, + /// [`FontFace::id`] of the face that supplied the glyph. + pub font_id: u32, +} + +/// One line of laid-out text. +/// +/// For type on a path there is a single line; `x0, baseline` is the start point of the text on the +/// path and `x1` the x of its end point. +#[derive(Clone, Debug)] +pub struct LineInfo { + pub baseline: f64, + /// Left edge of the line content (after alignment). + pub x0: f64, + /// Right edge of the line content, trailing spaces excluded. + pub x1: f64, + pub ascent: f64, + pub descent: f64, + /// Byte range of the line in the plain text (excludes the paragraph's `\n`). + pub start: usize, + pub end: usize, + /// Range of the line's glyphs in [`TextLayout::glyphs`]. + pub glyph_start: usize, + pub glyph_end: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct TextLayout { + pub glyphs: Vec, + pub lines: Vec, + /// Ink/advance bounds in text space. + pub bounds: Rect, + /// True if area text did not fit its frame (the red overflow "+" marker), or on-path text ran + /// past the end of the path. + pub overflow: bool, + /// True for type on a path (glyphs have individual `angle`s). + pub on_path: bool, +} + +impl TextLayout { + /// All glyph outlines combined (e.g. for Create Outlines). + pub fn to_bezpath(&self) -> BezPath { + let mut p = BezPath::new(); + for g in &self.glyphs { + p.extend(g.outline.iter()); + } + p + } + /// Index of the line containing byte offset `byte`. + pub fn line_of(&self, byte: usize) -> usize { + self.lines.iter().rposition(|l| l.start <= byte).unwrap_or(0) + } +} + +fn dir(angle: f64) -> Vec2 { + Vec2::new(angle.cos(), angle.sin()) +} + +/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. +pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { + let Some(line) = layout.lines.get(layout.line_of(byte)) else { + return (Point::ZERO, Point::ZERO); + }; + let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; + let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) { + let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; + (g.origin + dir(g.angle) * (g.advance * frac), g.angle) + } else if let Some(g) = glyphs.last() { + (g.origin + dir(g.angle) * g.advance, g.angle) + } else { + (Point::new(line.x0, line.baseline), 0.0) + }; + // Up vector in y-down space, rotated with the baseline. + let d = dir(angle); + let up = Vec2::new(d.y, -d.x); + (pos + up * line.ascent, pos - up * line.descent) +} + +/// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool). +pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { + if layout.on_path { + let best = layout.glyphs.iter().min_by(|a, b| { + let ca = a.origin + dir(a.angle) * (a.advance * 0.5); + let cb = b.origin + dir(b.angle) * (b.advance * 0.5); + (p - ca).hypot2().total_cmp(&(p - cb).hypot2()) + }); + return match best { + Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte, + Some(g) => g.byte + g.len, + None => layout.lines.first().map_or(0, |l| l.start), + }; + } + let Some(li) = layout.lines.iter().position(|l| p.y < l.baseline + l.descent).or(layout.lines.len().checked_sub(1)) else { + return 0; + }; + let line = &layout.lines[li]; + let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; + if let Some(g) = glyphs.iter().find(|g| p.x < g.origin.x + g.advance * 0.5) { + return g.byte; + } + // Past the end: a soft-wrapped line ends before its last (usually space) glyph so the caret + // stays on this line. + let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end); + match glyphs.last() { + Some(g) if soft_wrap => g.byte, + _ => line.end, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs new file mode 100644 index 000000000..457965557 --- /dev/null +++ b/crates/text/src/shape.rs @@ -0,0 +1,188 @@ +//! Shaping: text runs -> positioned glyphs in points (before line breaking). + +use std::ops::Range; +use std::sync::Arc; + +use drawcraft_doc::CharStyle; +use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; +use skrifa::MetadataProvider; +use skrifa::instance::{LocationRef, Size}; + +use crate::fontdb::{FontDb, FontFace}; + +/// A shaped glyph with all character-style effects resolved, in points (y down). +#[derive(Clone, Debug)] +pub(crate) struct SGlyph { + pub face: Arc, + pub gid: u32, + /// Byte offset of the cluster in the plain text, and its length in bytes. + pub byte: usize, + pub len: usize, + pub run: usize, + /// Advance in points (tracking, manual kerning and horizontal scale applied). + pub adv: f64, + /// Offset from the pen position, y down. + pub dx: f64, + pub dy: f64, + /// Outline scale (font units -> points). + pub sx: f64, + pub sy: f64, + /// Baseline shift in points (positive = up). + pub bshift: f64, + /// Character rotation in degrees (counter-clockwise). + pub rotation: f64, + pub ascent: f64, + pub descent: f64, + pub leading: f64, + /// First source character of the cluster. + pub ch: char, +} + +impl SGlyph { + pub fn is_space(&self) -> bool { + matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}') + } + /// A line may break after this glyph. + pub fn break_after(&self) -> bool { + self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch) + } +} + +fn is_cjk(c: char) -> bool { + matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) +} + +/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). +pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { + let face = db.face(&st.font_family, &st.font_style); + let k = st.size / face.upem; + let vs = st.v_scale / 100.0; + (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) +} + +/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style. +pub(crate) fn shape_range(db: &FontDb, text: &str, range: Range, runs: &[(Range, &CharStyle)], out: &mut Vec) { + for (ri, (rr, st)) in runs.iter().enumerate() { + let a = rr.start.max(range.start); + let b = rr.end.min(range.end); + if a >= b { + continue; + } + let primary = db.face(&st.font_family, &st.font_style); + let pmap = primary.skrifa().map(|f| f.charmap()); + // Split into segments by font coverage. + let mut seg_start = a; + let mut seg_face = primary.clone(); + let mut cache: Vec<(char, Arc)> = Vec::new(); + for (i, c) in text[a..b].char_indices() { + let i = a + i; + let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some()); + let face = if covered { + primary.clone() + } else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) { + f.clone() + } else { + let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()); + cache.push((c, f.clone())); + f + }; + // Combining marks stay with their base. + if face.id() != seg_face.id() && !is_mark(c) { + if i > seg_start { + shape_segment(text, seg_start..i, ri, st, &seg_face, out); + } + seg_start = i; + seg_face = face; + } + } + if b > seg_start { + shape_segment(text, seg_start..b, ri, st, &seg_face, out); + } + } +} + +fn is_mark(c: char) -> bool { + matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F) +} + +fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, face: &Arc, out: &mut Vec) { + let seg = &text[range.clone()]; + let size = st.size.max(0.0); + let k = size / face.upem; + let hs = st.h_scale / 100.0; + let vs = st.v_scale / 100.0; + let tracking = st.tracking / 1000.0 * size; + let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0); + let ascent = face.ascent * k * vs; + let descent = face.descent * k * vs; + let leading = st.effective_leading(); + let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); + + let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(seg.len()); // gid, cluster, xadv, xoff, yoff + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in seg.char_indices() { + let cl = (range.start + i) as u32; + if st.all_caps { + for u in c.to_uppercase() { + buf.add(u, cl); + } + } else { + buf.add(c, cl); + } + } + buf.set_direction(Direction::LeftToRight); + buf.guess_segment_properties(); + let feats: Vec = if st.kerning.is_some() { vec![Feature::new(Tag::new(b"kern"), 0, ..)] } else { Vec::new() }; + let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); + } + }); + if shaped.is_none() { + // Fallback: nominal glyphs and hmtx advances, no shaping. + if let Some(f) = face.skrifa() { + let cmap = f.charmap(); + let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default()); + for (i, c) in seg.char_indices() { + let cl = (range.start + i) as u32; + let chars: Vec = if st.all_caps { c.to_uppercase().collect() } else { vec![c] }; + for u in chars { + let g = cmap.map(u).unwrap_or_default(); + let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0)); + } + } + } + } + let n = raw.len(); + for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() { + let cl = cl as usize; + // Cluster end: the next larger cluster value in the segment, else the segment end. + let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end); + let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; + let mut adv = xa as f64 * k * hs; + if last_in_cluster { + adv += tracking + manual_kern; + } + out.push(SGlyph { + face: face.clone(), + gid, + byte: cl, + len: end.saturating_sub(cl).max(1), + run, + adv, + dx: xo as f64 * k * hs, + dy: -(yo as f64) * k * vs, + sx: k * hs, + sy: k * vs, + bshift: st.baseline_shift, + rotation: st.rotation, + ascent, + descent, + leading, + ch: first_char(cl), + }); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs new file mode 100644 index 000000000..a733fd57f --- /dev/null +++ b/crates/text/src/tests.rs @@ -0,0 +1,361 @@ +use super::*; +use drawcraft_doc::{CharStyle, Justify, TextKind, TextRun}; +use drawcraft_geom::PathData; +use kurbo::{Affine, Shape}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +fn serif(size: f64) -> CharStyle { + CharStyle { font_family: "Source Serif 4".into(), ..style(size) } +} + +fn point(text: &str, st: CharStyle) -> TextObject { + TextObject::point(Point::ZERO, text, st) +} + +fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject { + let mut t = point(text, st); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = justify; + t +} + +fn width(t: &TextObject) -> f64 { + let l = layout(db(), t); + l.glyphs.iter().map(|g| g.advance).sum() +} + +const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; + +#[test] +fn bundled_families_and_styles() { + let f = db().families(); + for fam in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { + assert!(f.iter().any(|x| x == fam), "{fam} missing from {f:?}"); + } + let s = db().styles("Source Sans 3"); + assert_eq!(s.first().map(String::as_str), Some("Regular"), "{s:?}"); + assert!(s.iter().any(|x| x == "Bold") && s.iter().any(|x| x == "Italic") && s.iter().any(|x| x == "Semibold"), "{s:?}"); +} + +#[test] +fn style_lookup_and_fallback() { + let f = db().face("Inter", "Semibold"); + assert_eq!((f.family.as_str(), f.style.as_str()), ("Inter", "SemiBold")); + let f = db().face("inter", "Bold"); + assert_eq!(f.style, "SemiBold", "closest weight"); + let f = db().face("No Such Font", "Regular"); + assert_eq!((f.family.as_str(), f.style.as_str()), ("Source Sans 3", "Regular")); + let f = db().face("No Such Font", "Bold"); + assert_eq!(f.style, "Bold"); + assert_eq!(db().face("Source Sans 3", "Italic").style, "Italic"); +} + +#[test] +fn add_font_rejects_garbage_and_duplicates() { + assert_eq!(db().add_font(vec![1, 2, 3]), 0); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + assert_eq!(db().add_font(bytes), 0, "already bundled"); +} + +#[test] +fn glyph_count_and_advances() { + let l = layout(db(), &point("Hello", style(24.0))); + assert_eq!(l.glyphs.len(), 5); + assert_eq!(l.lines.len(), 1); + for (i, g) in l.glyphs.iter().enumerate() { + assert!(g.advance > 0.0); + assert_eq!(g.byte, i); + assert!(!g.outline.elements().is_empty()); + } + // Glyphs sit on the baseline (y = 0), ascenders above it (y < 0, y-down). + let h = l.glyphs[0].outline.bounding_box(); + assert!(h.y0 < -10.0 && h.y1.abs() < 1.0, "{h:?}"); + assert!(l.bounds.width() > 30.0); +} + +#[test] +fn ligatures_and_kerning() { + // "fi" forms a ligature in Source Serif 4: one glyph covering two bytes. + let l = layout(db(), &point("fi", serif(20.0))); + assert_eq!(l.glyphs.len(), 1); + assert_eq!(l.glyphs[0].len, 2); + // Metrics kerning tightens "AV"; manual kerning 0 disables it. + let auto = width(&point("AV", style(100.0))); + let off = width(&point("AV", CharStyle { kerning: Some(0.0), ..style(100.0) })); + assert!(auto < off - 1.0, "auto {auto} off {off}"); +} + +#[test] +fn point_text_lines_and_auto_leading() { + let l = layout(db(), &point("one\ntwo\n", style(10.0))); + assert_eq!(l.lines.len(), 3); + assert_eq!(l.lines[0].baseline, 0.0); + assert!((l.lines[1].baseline - 12.0).abs() < 1e-9); + assert!((l.lines[2].baseline - 24.0).abs() < 1e-9); + assert_eq!((l.lines[0].start, l.lines[0].end), (0, 3)); + assert_eq!((l.lines[1].start, l.lines[1].end), (4, 7)); + assert_eq!((l.lines[2].start, l.lines[2].end), (8, 8)); +} + +#[test] +fn explicit_leading() { + let l = layout(db(), &point("a\nb", CharStyle { leading: Some(30.0), ..style(10.0) })); + assert!((l.lines[1].baseline - 30.0).abs() < 1e-9); +} + +#[test] +fn point_alignment() { + let mut t = point("Centered", style(20.0)); + let w = width(&t); + t.para.justify = Justify::Center; + let l = layout(db(), &t); + assert!((l.lines[0].x0 + w / 2.0).abs() < 1e-6 && (l.lines[0].x1 - w / 2.0).abs() < 1e-6); + t.para.justify = Justify::Right; + let l = layout(db(), &t); + assert!(l.lines[0].x1.abs() < 1e-6); + assert!(l.glyphs[0].origin.x < -w + 1e-6); +} + +#[test] +fn tracking_and_scale() { + let base = width(&point("abcd", style(10.0))); + let tracked = width(&point("abcd", CharStyle { tracking: 100.0, ..style(10.0) })); + assert!((tracked - base - 4.0 * 1.0).abs() < 1e-6, "{base} {tracked}"); + let wide = width(&point("abcd", CharStyle { h_scale: 200.0, ..style(10.0) })); + assert!((wide - 2.0 * base).abs() < 0.05); + let l = layout(db(), &point("x", CharStyle { baseline_shift: 5.0, ..style(10.0) })); + let b = l.glyphs[0].outline.bounding_box(); + assert!(b.y1 < -4.0, "shifted up: {b:?}"); +} + +#[test] +fn all_caps() { + let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) })); + let b = width(&point("ABC", style(10.0))); + assert!((a - b).abs() < 1e-9); + let l = layout(db(), &point("\u{df}", CharStyle { all_caps: true, ..style(10.0) })); + assert_eq!(l.glyphs.len(), 2, "ß -> SS"); +} + +#[test] +fn area_line_breaking_within_frame() { + let frame = Rect::new(10.0, 10.0, 110.0, 400.0); + let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Left)); + assert!(l.lines.len() > 3); + assert!(!l.overflow); + for line in &l.lines { + assert!(line.x0 >= frame.x0 - 1e-6 && line.x1 <= frame.x1 + 1e-6, "{line:?}"); + } + // Lines tile the text. + for w in l.lines.windows(2) { + assert_eq!(w[0].end, w[1].start); + } + assert_eq!(l.lines.last().unwrap().end, LOREM.len()); + // First baseline = frame top + ascent; subsequent lines advance by the leading. + assert!((l.lines[0].baseline - (frame.y0 + l.lines[0].ascent)).abs() < 1e-6); + assert!((l.lines[1].baseline - l.lines[0].baseline - 14.4).abs() < 1e-6); + // Lines start at word boundaries. + for line in &l.lines[1..] { + assert_eq!(&LOREM[line.start - 1..line.start], " "); + } +} + +#[test] +fn area_overflow() { + let small = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left)); + assert!(small.overflow); + assert!(!small.lines.is_empty() && small.lines.last().unwrap().end < LOREM.len()); + let big = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 1000.0, 400.0), Justify::Left)); + assert!(!big.overflow); + assert_eq!(big.glyphs.len(), LOREM.chars().count()); +} + +#[test] +fn area_alignment_and_justify() { + let frame = Rect::new(0.0, 0.0, 150.0, 400.0); + let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Right)); + for line in &l.lines { + assert!((line.x1 - frame.x1).abs() < 1e-6); + } + let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::JustifyLeft)); + let n = l.lines.len(); + for line in &l.lines[..n - 1] { + assert!((line.x0 - frame.x0).abs() < 1e-6 && (line.x1 - frame.x1).abs() < 1e-6, "{line:?}"); + } + assert!(l.lines[n - 1].x1 < frame.x1 - 1.0, "last line not justified"); + let l = layout(db(), &area("Wide", style(12.0), frame, Justify::JustifyAll)); + assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6); +} + +#[test] +fn indents_and_paragraph_spacing() { + let frame = Rect::new(0.0, 0.0, 200.0, 400.0); + let mut t = area("para one\npara two", style(10.0), frame, Justify::Left); + t.para.left_indent = 10.0; + t.para.first_line_indent = 5.0; + t.para.space_before = 6.0; + let l = layout(db(), &t); + assert!((l.lines[0].x0 - 15.0).abs() < 1e-9); + assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9); +} + +#[test] +fn non_rect_frame_narrows_lines() { + // Triangle pointing up: lines get wider towards the bottom. + let tri = PathData::from_bezpath(&{ + let mut p = BezPath::new(); + p.move_to((100.0, 0.0)); + p.line_to((200.0, 200.0)); + p.line_to((0.0, 200.0)); + p.close_path(); + p + }); + let mut t = point(&LOREM.repeat(2), style(10.0)); + t.kind = TextKind::Area { frame: tri }; + let l = layout(db(), &t); + assert!(l.lines.len() > 5); + let w0 = l.lines[0].x1 - l.lines[0].x0; + let wl = l.lines[l.lines.len() - 2].x1 - l.lines[l.lines.len() - 2].x0; + assert!(w0 < wl, "{w0} {wl}"); + for line in &l.lines { + let y = line.baseline - line.ascent; + let half = y / 2.0; // half-width of the triangle at y + assert!(line.x0 >= 100.0 - half - 1e-3 && line.x1 <= 100.0 + half + 1e-3, "{line:?}"); + } +} + +#[test] +fn caret_mapping_round_trip() { + let l = layout(db(), &point("Hi there\nnext", style(20.0))); + let (top, bot) = caret_position(&l, 0); + assert!(top.x.abs() < 1e-9 && top.y < 0.0 && bot.y > 0.0); + let (end_top, _) = caret_position(&l, 8); + assert!((end_top.x - l.lines[0].x1).abs() < 1e-6); + for b in [0, 1, 3, 8, 9, 11, 13] { + let (t, bo) = caret_position(&l, b); + let mid = t.midpoint(bo); + assert_eq!(hit_byte(&l, mid + Vec2::new(0.1, 0.0)), b, "byte {b}"); + } + // Second line. + let (t2, b2) = caret_position(&l, 10); + assert!(t2.y > 0.0 && b2.y > 24.0); + assert_eq!(hit_byte(&l, Point::new(1000.0, 24.0)), 13); + assert_eq!(hit_byte(&l, Point::new(-50.0, -100.0)), 0); +} + +#[test] +fn caret_inside_ligature_and_empty_text() { + let l = layout(db(), &point("fi", serif(20.0))); + let (a, _) = caret_position(&l, 1); + let g = &l.glyphs[0]; + assert!((a.x - g.advance / 2.0).abs() < 1e-6); + let e = layout(db(), &point("", style(20.0))); + assert_eq!(e.lines.len(), 1); + let (t, b) = caret_position(&e, 0); + assert!(t.y < 0.0 && b.y > 0.0); +} + +#[test] +fn on_path_placement() { + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("Path", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 }; + let l = layout(db(), &t); + assert!(l.on_path && !l.overflow); + assert_eq!(l.glyphs.len(), 4); + assert!((l.glyphs[0].origin.x - 50.0).abs() < 1e-6); + for g in &l.glyphs { + assert!((g.origin.y - 50.0).abs() < 1e-6 && g.angle.abs() < 1e-6); + } + // Vertical path downward: glyphs rotated 90°. + let mut v = BezPath::new(); + v.move_to((0.0, 0.0)); + v.line_to((0.0, 300.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 }; + let l = layout(db(), &t); + for g in &l.glyphs { + assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3); + assert!(g.origin.x.abs() < 1e-6); + } + let (top, bot) = caret_position(&l, 0); + assert!(top.x > 0.0 && bot.x < 0.0, "caret perpendicular to path: {top:?} {bot:?}"); + let mid = l.glyphs[1].origin + Vec2::new(0.0, 0.2); + assert_eq!(hit_byte(&l, mid), 1); +} + +#[test] +fn on_path_circle_and_overflow() { + let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1); + let mut t = point("Around the circle", style(14.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 }; + let l = layout(db(), &t); + assert!(!l.overflow); + for g in &l.glyphs { + let r = g.origin.to_vec2().hypot(); + assert!((r - 100.0).abs() < 2.0, "{r}"); + } + let mut short = BezPath::new(); + short.move_to((0.0, 0.0)); + short.line_to((30.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 }; + let l = layout(db(), &t); + assert!(l.overflow && l.glyphs.len() < t.plain_text().len()); +} + +#[test] +fn fallback_font_per_character() { + let primary = db().face("Source Sans 3", "Regular"); + // Find a character the primary lacks but another bundled font has. + let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}'] + .into_iter() + .find(|&c| !primary.covers(c) && db().fallback_for(c, primary.id()).is_some()) + .expect("some symbol only covered by a non-primary bundled font"); + let text = format!("a{c}b"); + let l = layout(db(), &point(&text, style(12.0))); + assert_eq!(l.glyphs.len(), 3); + assert_eq!(l.glyphs[0].font_id, primary.id()); + assert_ne!(l.glyphs[1].font_id, primary.id()); + assert!(!l.glyphs[1].outline.elements().is_empty()); + assert_eq!(l.glyphs[2].font_id, primary.id()); +} + +#[test] +fn multiple_runs_and_styles() { + let mut t = point("", style(10.0)); + t.runs = vec![ + TextRun { text: "Big".into(), style: style(40.0) }, + TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) } }, + ]; + let l = layout(db(), &t); + assert_eq!(l.glyphs.len(), 8); + assert_eq!(l.glyphs[3].run, 1); + assert!(l.lines[0].ascent > 30.0); + assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id); +} + +#[test] +fn layout_is_fast() { + let text: String = LOREM.chars().cycle().take(1000).collect(); + let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 2000.0), Justify::JustifyLeft); + let _ = layout(db(), &t); // warm caches + let n = 20; + let start = std::time::Instant::now(); + for _ in 0..n { + let l = layout(db(), &t); + assert_eq!(l.glyphs.len(), 1000); + } + let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64; + eprintln!("layout of 1000 chars: {per:.3} ms"); + let budget = if cfg!(debug_assertions) { 100.0 } else { 5.0 }; + assert!(per < budget, "{per} ms"); +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml new file mode 100644 index 000000000..147e76b1c --- /dev/null +++ b/crates/tools/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "drawcraft-tools" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DrawCraft tool state machines: pointer events in, commands and overlays out" + +[lints] +workspace = true + +[dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-text = { workspace = true } +kurbo = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/tools/src/bbox.rs b/crates/tools/src/bbox.rs new file mode 100644 index 000000000..ae75f5ed8 --- /dev/null +++ b/crates/tools/src/bbox.rs @@ -0,0 +1,150 @@ +//! Bounding-box handle geometry shared by the Selection tool and the UI. + +use drawcraft_geom::{Affine, Point, Rect, Vec2}; + +/// The 8 handles, clockwise from top-left. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Handle { + TopLeft, + Top, + TopRight, + Right, + BottomRight, + Bottom, + BottomLeft, + Left, +} + +impl Handle { + pub const ALL: [Handle; 8] = + [Handle::TopLeft, Handle::Top, Handle::TopRight, Handle::Right, Handle::BottomRight, Handle::Bottom, Handle::BottomLeft, Handle::Left]; + + pub fn pos(self, r: Rect) -> Point { + let c = r.center(); + match self { + Handle::TopLeft => Point::new(r.x0, r.y0), + Handle::Top => Point::new(c.x, r.y0), + Handle::TopRight => Point::new(r.x1, r.y0), + Handle::Right => Point::new(r.x1, c.y), + Handle::BottomRight => Point::new(r.x1, r.y1), + Handle::Bottom => Point::new(c.x, r.y1), + Handle::BottomLeft => Point::new(r.x0, r.y1), + Handle::Left => Point::new(r.x0, c.y), + } + } + pub fn opposite(self) -> Handle { + Handle::ALL[(self as usize + 4) % 8] + } + pub fn is_corner(self) -> bool { + (self as usize).is_multiple_of(2) + } + /// Which axes this handle scales (x, y). + pub fn axes(self) -> (bool, bool) { + match self { + Handle::Top | Handle::Bottom => (false, true), + Handle::Left | Handle::Right => (true, false), + _ => (true, true), + } + } +} + +/// Handle under `p` (within `tol` document units). +pub fn hit_handle(r: Rect, p: Point, tol: f64) -> Option { + Handle::ALL.into_iter().find(|h| { + let q = h.pos(r); + (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol + }) +} + +/// Is `p` in the rotate zone just outside a corner (Illustrator shows the curved rotate cursor there)? +pub fn in_rotate_zone(r: Rect, p: Point, inner: f64, outer: f64) -> Option { + [Handle::TopLeft, Handle::TopRight, Handle::BottomRight, Handle::BottomLeft].into_iter().find(|h| { + let q = h.pos(r); + let d = p.distance(q); + d > inner && d <= outer && !r.contains(p) + }) +} + +/// Scale transform for dragging `handle` of `r` to `p`. +/// `proportional` (Shift) keeps the aspect ratio; `from_center` (Alt) scales about the centre. +pub fn scale_for_drag(r: Rect, handle: Handle, p: Point, proportional: bool, from_center: bool) -> Affine { + let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; + let start = handle.pos(r); + let (ax, ay) = handle.axes(); + let d0 = start - origin; + let d1 = p - origin; + let mut sx = if ax && d0.x.abs() > 1e-9 { d1.x / d0.x } else { 1.0 }; + let mut sy = if ay && d0.y.abs() > 1e-9 { d1.y / d0.y } else { 1.0 }; + if proportional { + if handle.is_corner() { + let s = if sx.abs() > sy.abs() { sx } else { sy }; + sx = s.abs() * sx.signum(); + sy = s.abs() * sy.signum(); + } else if ax { + sy = sx.abs(); + } else { + sx = sy.abs(); + } + } + // Avoid degenerate zero scales (Illustrator never collapses an object completely). + let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4 * if s < 0.0 { -1.0 } else { 1.0 } } else { s }; + Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(clamp(sx), clamp(sy)) * Affine::translate(-origin.to_vec2()) +} + +/// Rotation about `center` taking `from` to `to`; `snap` constrains to 45° steps. +pub fn rotate_for_drag(center: Point, from: Point, to: Point, snap: bool) -> (Affine, f64) { + let a0 = (from - center).atan2(); + let a1 = (to - center).atan2(); + let mut a = a1 - a0; + if snap { + let step = std::f64::consts::FRAC_PI_4; + a = (a / step).round() * step; + } + (Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()), -a.to_degrees()) +} + +/// Move constrained to 45° multiples when `shift`. +pub fn move_delta(from: Point, to: Point, shift: bool) -> Vec2 { + let d = to - from; + if shift { drawcraft_geom::constrain_angle(d, 45.0) } else { d } +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: Rect = Rect::new(0.0, 0.0, 100.0, 50.0); + + #[test] + fn handle_positions() { + assert_eq!(Handle::Right.pos(R), Point::new(100.0, 25.0)); + assert_eq!(Handle::TopLeft.opposite(), Handle::BottomRight); + assert_eq!(Handle::Top.opposite(), Handle::Bottom); + assert_eq!(hit_handle(R, Point::new(99.0, 24.0), 3.0), Some(Handle::Right)); + assert_eq!(hit_handle(R, Point::new(50.0, 25.0), 3.0), None); + } + + #[test] + fn scale_corner_free_and_proportional() { + let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), false, false); + assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 50.0)); + let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), true, false); + assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 100.0)); + } + + #[test] + fn scale_side_and_center() { + let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 999.0), false, false); + assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 150.0, 50.0)); + let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 25.0), false, true); + assert_eq!(a.transform_rect_bbox(R), Rect::new(-50.0, 0.0, 150.0, 50.0)); + } + + #[test] + fn rotate_snaps() { + let (_, deg) = rotate_for_drag(Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 9.0), true); + assert!((deg + 45.0).abs() < 1e-9); + assert!(in_rotate_zone(R, Point::new(-8.0, -8.0), 4.0, 20.0).is_some()); + assert!(in_rotate_zone(R, Point::new(5.0, 5.0), 4.0, 20.0).is_none()); + } +} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs new file mode 100644 index 000000000..c4b5301e3 --- /dev/null +++ b/crates/tools/src/catalog.rs @@ -0,0 +1,179 @@ +//! The tool catalogue: ids, labels, shortcuts and toolbar groups (Illustrator's Advanced toolbar order). + +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +pub struct ToolInfo { + pub id: &'static str, + pub label: &'static str, + /// Single-key shortcut (Shift+key shown as "Shift+X"). + pub shortcut: Option<&'static str>, + /// Icon name in the UI icon set. + pub icon: &'static str, +} + +const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str>, icon: &'static str) -> ToolInfo { + ToolInfo { id, label, shortcut, icon } +} + +/// Toolbar groups in order; the first tool of each group is the default visible one. +pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ + &[t("selection", "Selection Tool", Some("V"), "tool-selection")], + &[ + t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct"), + t("groupSelection", "Group Selection Tool", None, "tool-group-select"), + ], + &[t("magicWand", "Magic Wand Tool", Some("Y"), "tool-magic-wand")], + &[t("lasso", "Lasso Tool", Some("Q"), "tool-lasso")], + &[ + t("pen", "Pen Tool", Some("P"), "tool-pen"), + t("addAnchor", "Add Anchor Point Tool", Some("+"), "tool-pen-add"), + t("deleteAnchor", "Delete Anchor Point Tool", Some("-"), "tool-pen-delete"), + t("anchorPoint", "Anchor Point Tool", Some("Shift+C"), "tool-anchor"), + ], + &[t("curvature", "Curvature Tool", Some("Shift+~"), "tool-curvature")], + &[ + t("type", "Type Tool", Some("T"), "tool-type"), + t("areaType", "Area Type Tool", None, "tool-type-area"), + t("typeOnPath", "Type on a Path Tool", None, "tool-type-path"), + t("verticalType", "Vertical Type Tool", None, "tool-type-vertical"), + t("verticalAreaType", "Vertical Area Type Tool", None, "tool-type-vertical"), + t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-path"), + t("touchType", "Touch Type Tool", Some("Shift+T"), "tool-touch-type"), + ], + &[ + t("lineSegment", "Line Segment Tool", Some("\\"), "tool-line"), + t("arc", "Arc Tool", None, "tool-arc"), + t("spiral", "Spiral Tool", None, "tool-spiral"), + t("rectangularGrid", "Rectangular Grid Tool", None, "tool-rect-grid"), + t("polarGrid", "Polar Grid Tool", None, "tool-polar-grid"), + ], + &[ + t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"), + t("roundedRectangle", "Rounded Rectangle Tool", None, "tool-rounded-rect"), + t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"), + t("polygon", "Polygon Tool", None, "tool-polygon"), + t("star", "Star Tool", None, "tool-star"), + t("flare", "Flare Tool", None, "tool-flare"), + ], + &[t("paintbrush", "Paintbrush Tool", Some("B"), "tool-brush"), t("blobBrush", "Blob Brush Tool", Some("Shift+B"), "tool-blob-brush")], + &[ + t("shaper", "Shaper Tool", Some("Shift+N"), "tool-shaper"), + t("pencil", "Pencil Tool", Some("N"), "tool-pencil"), + t("smooth", "Smooth Tool", None, "tool-smooth"), + t("pathEraser", "Path Eraser Tool", None, "tool-path-eraser"), + t("join", "Join Tool", None, "tool-join"), + ], + &[ + t("eraser", "Eraser Tool", Some("Shift+E"), "tool-eraser"), + t("scissors", "Scissors Tool", Some("C"), "tool-scissors"), + t("knife", "Knife", None, "tool-knife"), + ], + &[t("rotate", "Rotate Tool", Some("R"), "tool-rotate"), t("reflect", "Reflect Tool", Some("O"), "tool-reflect")], + &[ + t("scale", "Scale Tool", Some("S"), "tool-scale"), + t("shear", "Shear Tool", None, "tool-shear"), + t("reshape", "Reshape Tool", None, "tool-reshape"), + ], + &[ + t("width", "Width Tool", Some("Shift+W"), "tool-width"), + t("warp", "Warp Tool", Some("Shift+R"), "tool-warp"), + t("twirl", "Twirl Tool", None, "tool-twirl"), + t("pucker", "Pucker Tool", None, "tool-pucker"), + t("bloat", "Bloat Tool", None, "tool-bloat"), + t("scallop", "Scallop Tool", None, "tool-scallop"), + t("crystallize", "Crystallize Tool", None, "tool-crystallize"), + t("wrinkle", "Wrinkle Tool", None, "tool-wrinkle"), + ], + &[t("freeTransform", "Free Transform Tool", Some("E"), "tool-free-transform"), t("puppetWarp", "Puppet Warp Tool", None, "tool-puppet")], + &[ + t("shapeBuilder", "Shape Builder Tool", Some("Shift+M"), "tool-shape-builder"), + t("livePaintBucket", "Live Paint Bucket", Some("K"), "tool-bucket"), + t("livePaintSelection", "Live Paint Selection Tool", Some("Shift+L"), "tool-live-select"), + ], + &[ + t("perspectiveGrid", "Perspective Grid Tool", Some("Shift+P"), "tool-perspective"), + t("perspectiveSelection", "Perspective Selection Tool", Some("Shift+V"), "tool-perspective-select"), + ], + &[t("mesh", "Mesh Tool", Some("U"), "tool-mesh")], + &[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")], + &[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")], + &[t("blend", "Blend Tool", Some("W"), "tool-blend")], + &[ + t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"), + t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"), + t("symbolScruncher", "Symbol Scruncher Tool", None, "tool-symbol"), + t("symbolSizer", "Symbol Sizer Tool", None, "tool-symbol"), + t("symbolSpinner", "Symbol Spinner Tool", None, "tool-symbol"), + t("symbolStainer", "Symbol Stainer Tool", None, "tool-symbol"), + t("symbolScreener", "Symbol Screener Tool", None, "tool-symbol"), + t("symbolStyler", "Symbol Styler Tool", None, "tool-symbol"), + ], + &[ + t("columnGraph", "Column Graph Tool", Some("J"), "tool-graph"), + t("stackedColumnGraph", "Stacked Column Graph Tool", None, "tool-graph"), + t("barGraph", "Bar Graph Tool", None, "tool-graph"), + t("stackedBarGraph", "Stacked Bar Graph Tool", None, "tool-graph"), + t("lineGraph", "Line Graph Tool", None, "tool-graph"), + t("areaGraph", "Area Graph Tool", None, "tool-graph"), + t("scatterGraph", "Scatter Graph Tool", None, "tool-graph"), + t("pieGraph", "Pie Graph Tool", None, "tool-graph"), + t("radarGraph", "Radar Graph Tool", None, "tool-graph"), + ], + &[ + t("artboard", "Artboard Tool", Some("Shift+O"), "tool-artboard"), + t("slice", "Slice Tool", Some("Shift+K"), "tool-slice"), + t("sliceSelection", "Slice Selection Tool", None, "tool-slice"), + ], + &[ + t("hand", "Hand Tool", Some("H"), "tool-hand"), + t("rotateView", "Rotate View Tool", Some("Shift+H"), "tool-rotate-view"), + t("printTiling", "Print Tiling Tool", None, "tool-print-tiling"), + ], + &[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")], +]; + +pub fn all_tools() -> impl Iterator { + TOOL_GROUPS.iter().flat_map(|g| g.iter()) +} + +pub fn tool_info(id: &str) -> Option<&'static ToolInfo> { + all_tools().find(|t| t.id == id) +} + +/// Tool for a single-key shortcut such as "V" or "Shift+M". +pub fn tool_for_shortcut(s: &str) -> Option<&'static ToolInfo> { + all_tools().find(|t| t.shortcut == Some(s)) +} + +/// Index of the group containing `id`. +pub fn group_of(id: &str) -> Option { + TOOL_GROUPS.iter().position(|g| g.iter().any(|t| t.id == id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unique_ids_and_shortcuts() { + let mut ids: Vec<&str> = all_tools().map(|t| t.id).collect(); + let n = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate tool ids"); + let mut sc: Vec<&str> = all_tools().filter_map(|t| t.shortcut).collect(); + let n = sc.len(); + sc.sort(); + sc.dedup(); + assert_eq!(sc.len(), n, "duplicate shortcuts"); + assert!(n > 40); + } + + #[test] + fn lookups() { + assert_eq!(tool_for_shortcut("V").unwrap().id, "selection"); + assert_eq!(tool_for_shortcut("Shift+M").unwrap().id, "shapeBuilder"); + assert_eq!(group_of("star"), group_of("rectangle")); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs new file mode 100644 index 000000000..7c977d8db --- /dev/null +++ b/crates/tools/src/direct.rs @@ -0,0 +1,259 @@ +//! Direct Selection (A) and Group Selection tools. +//! +//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the +//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to +//! reshape, marquee to select anchors. Group Selection: click selects the leaf; each further click +//! on it adds the next enclosing group. + +use drawcraft_doc::hit::hit_test; +use drawcraft_doc::{AnchorRef, NodeId, NodeKind}; +use drawcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::bbox::move_delta; +use crate::select::matrix_json; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Clone, Debug)] +enum State { + Idle, + MoveAnchors { start: Point, began: bool }, + MoveObject { start: Point, began: bool }, + Handle { id: NodeId, si: usize, ai: usize, out: bool }, + Marquee { start: Point, cur: Point, add: bool }, +} + +pub struct DirectSelectionTool { + group: bool, + state: State, +} + +impl DirectSelectionTool { + pub fn new(group: bool) -> Self { + Self { group, state: State::Idle } + } +} + +/// Anchor (or handle) of any selected/visible path under `p`. +fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> { + let ids: Vec = if selected_only { + cx.selection.objects.clone() + } else { + let mut v = vec![]; + cx.doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. }) { + v.push(n.id) + } + }); + v.reverse(); + v + }; + for id in ids { + if !cx.doc.is_editable(id) { + continue; + } + if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) { + for (si, ai, a) in pd.anchors() { + if a.p.distance(p) <= tol { + return Some((id, si, ai)); + } + } + } + } + None +} + +/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out). +fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> { + for (id, set) in &cx.selection.anchors { + let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; + for &(si, ai) in set { + let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue }; + 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 +} + +fn anchors_json(v: &[AnchorRef]) -> Value { + Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect()) +} + +impl Tool for DirectSelectionTool { + fn id(&self) -> &'static str { + if self.group { "groupSelection" } else { "directSelection" } + } + fn busy(&self) -> bool { + !matches!(self.state, State::Idle) + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let tol = cx.tol(4.0); + match (ev.kind, self.state.clone()) { + (PointerKind::Down, _) if self.group => { + let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { + self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift }; + return vec![]; + }; + // Walk up from the leaf: select the first ancestor not yet selected (below the layer). + let chain: Vec = h.ancestry.iter().skip(1).rev().copied().collect(); + let target = if cx.selection.contains(h.leaf) { + chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) + } else { + h.leaf + }; + self.state = State::MoveObject { start: p, began: false }; + if cx.selection.contains(target) { + vec![] + } else if ev.mods.shift || cx.selection.contains(h.leaf) { + vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))] + } else { + vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))] + } + } + (PointerKind::Down, _) => { + if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + self.state = State::Handle { id, si, ai, out }; + return vec![Action::Begin("Reshape".into())]; + } + if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) { + let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai))); + self.state = State::MoveAnchors { start: p, began: false }; + if ev.mods.shift { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; + } + if !already { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]; + } + return vec![]; + } + if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + // Clicking a segment/fill selects the whole leaf path (all anchors). + self.state = State::MoveObject { start: p, began: false }; + if ev.mods.shift { + return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; + } + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; + } + return vec![]; + } + self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift }; + vec![] + } + (PointerKind::Drag, State::MoveAnchors { start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Move".into())); + self.state = State::MoveAnchors { start, began: true }; + } + let d = move_delta(start, p, ev.mods.shift); + out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); + out + } + (PointerKind::Drag, State::MoveObject { start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Move".into())); + self.state = State::MoveObject { start, began: true }; + } + let d = move_delta(start, p, ev.mods.shift); + out.push(Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(drawcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), + )); + out + } + (PointerKind::Drag, State::Handle { id, si, ai, out }) => { + vec![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": ev.mods.alt}), + )] + } + (PointerKind::Drag, State::Marquee { start, add, .. }) => { + self.state = State::Marquee { start, cur: p, add }; + vec![] + } + (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => { + self.state = State::Idle; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Up, State::Handle { .. }) => { + self.state = State::Idle; + vec![Action::Commit] + } + (PointerKind::Up, State::Marquee { start, add, .. }) => { + self.state = State::Idle; + let r = Rect::from_points(start, p); + if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { + return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + } + // Collect anchors inside the rect for every editable path. + let mut sel: Vec<(NodeId, Vec)> = vec![]; + cx.doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + let v: Vec = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); + if !v.is_empty() { + sel.push((n.id, v)); + } + } + }); + sel.retain(|(id, _)| cx.doc.is_editable(*id)); + let items: Vec = sel.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 overlays(&self, _cx: &ToolContext) -> Vec { + match self.state { + State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(start, cur))], + _ => vec![], + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::ArrowHollow + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn click_anchor_selects_it() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); + } + + #[test] + fn marquee_selects_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs new file mode 100644 index 000000000..41b9e7624 --- /dev/null +++ b/crates/tools/src/lib.rs @@ -0,0 +1,282 @@ +//! DrawCraft tools: pointer events in → commands and overlays out. +//! +//! Tools never mutate the document directly. They emit [`Action`]s that the engine executes: +//! `Begin` snapshots the document, each `Preview` re-applies one command on top of that snapshot +//! (replacing the previous preview), and `Commit` records the last preview as a single undo step +//! and journal entry. One-shot `Exec` actions run immediately. This makes every gesture replayable +//! by the control channel and MCP, and keeps tools testable without a UI. +#![forbid(unsafe_code)] + +pub mod bbox; +pub mod catalog; +pub mod direct; +pub mod pen; +pub mod select; +pub mod shape; + +use drawcraft_color::Paint; +use drawcraft_doc::{Document, NodeId, Selection}; +use drawcraft_geom::{BezPath, Point, Rect}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; + +/// Modifier keys. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mods { + #[serde(default)] + pub shift: bool, + /// Option on macOS. + #[serde(default)] + pub alt: bool, + /// Command on macOS, Ctrl elsewhere. + #[serde(default)] + pub cmd: bool, + #[serde(default)] + pub ctrl: bool, + #[serde(default)] + pub space: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PointerKind { + /// Button pressed. + Down, + /// Moved with the button held. + Drag, + /// Button released. + Up, + /// Moved with no button (hover). + Move, + DoubleClick, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PointerEvent { + pub kind: PointerKind, + /// Document coordinates. + pub pos: Point, + #[serde(default)] + pub mods: Mods, + #[serde(default = "one")] + pub pressure: f32, +} + +fn one() -> f32 { + 1.0 +} + +impl PointerEvent { + pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { + Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 } + } + pub fn with_mods(mut self, m: Mods) -> Self { + self.mods = m; + self + } +} + +/// Keys tools care about. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolKey { + Enter, + Escape, + Backspace, + Delete, + Up, + Down, + Left, + Right, + /// Illustrator: ↑/↓ while drawing a polygon/star change sides/points; `[`/`]` change sizes. + BracketLeft, + BracketRight, + Tab, +} + +/// What a tool asks the engine to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + /// Start an interaction (snapshot the document). The label becomes the undo name. + Begin(String), + /// Replace the current preview with this command, applied to the snapshot. + Preview(String, Value), + /// Finish the interaction: keep the last preview as one undo step. + Commit, + /// Abort the interaction: restore the snapshot. + Cancel, + /// Execute a command immediately (its own undo step, if it edits). + Exec(String, Value), + /// Ask the UI to open a dialog (e.g. click with the Rectangle tool → size dialog). + Dialog(String, Value), + /// Switch to another tool (e.g. after placing a text frame). + SwitchTool(String), +} + +/// Paint defaults for new art (the fill/stroke proxy). +#[derive(Clone, Debug, PartialEq)] +pub struct PaintDefaults { + pub fill: Paint, + pub stroke: Paint, + pub stroke_width: f64, +} + +/// Read-only context a tool sees. +pub struct ToolContext<'a> { + pub doc: &'a Document, + pub selection: &'a Selection, + /// Screen pixels per document point. + pub zoom: f64, + pub isolation: Option, + pub paint: &'a PaintDefaults, + pub outline: bool, + pub smart_guides: bool, + pub snap_to_grid: bool, + /// Bounding box shown (View → Show/Hide Bounding Box). + pub show_bbox: bool, +} + +impl ToolContext<'_> { + /// Tolerance in document units for `px` screen pixels. + pub fn tol(&self, px: f64) -> f64 { + px / self.zoom.max(1e-9) + } + pub fn hit_options(&self) -> drawcraft_doc::hit::HitOptions { + drawcraft_doc::hit::HitOptions { tol: self.tol(3.0), outline: self.outline, path_only: false } + } +} + +/// Visual feedback drawn by the UI in screen space (coordinates here are document space). +#[derive(Clone, Debug, PartialEq)] +pub enum Overlay { + /// Marquee / rubber band rectangle. + Marquee(Rect), + /// A path preview (e.g. pen rubber band) in a colour (RGB). + Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, + /// A line segment (smart guide, handle line). + Line { a: Point, b: Point, color: [u8; 3], dashed: bool }, + /// An anchor square: filled = selected. + Anchor { p: Point, color: [u8; 3], filled: bool, size: f32 }, + /// A handle end circle. + Handle { p: Point, color: [u8; 3] }, + /// Smart-guide style label (e.g. "anchor", "W: 100 pt"). + Label { p: Point, text: String, color: [u8; 3] }, + /// Measurement pill near the cursor (grey box with white text). + Measure { p: Point, text: String }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Cursor { + #[default] + Arrow, + ArrowHollow, + Move, + Crosshair, + ResizeH, + ResizeV, + ResizeNwSe, + ResizeNeSw, + Rotate, + Pen, + PenAdd, + PenDelete, + PenClose, + PenContinue, + Text, + Hand, + HandGrab, + ZoomIn, + ZoomOut, + Eyedropper, + NotAllowed, +} + +/// A tool state machine. +pub trait Tool: Send { + fn id(&self) -> &'static str; + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec; + fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec { + vec![] + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + vec![] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Arrow + } + /// Tool options as JSON (shown by the Control bar / tool options dialog). + fn options(&self) -> Value { + Value::Null + } + fn set_option(&mut self, _key: &str, _value: &Value) {} + /// Is an interaction in progress (drag, open pen path)? + fn busy(&self) -> bool { + false + } + /// Called when the user switches away (finish pending work). + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } +} + +/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. +pub fn create(id: &str) -> Box { + match id { + "selection" => Box::new(select::SelectionTool::default()), + "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), + "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), + "pen" => Box::new(pen::PenTool::default()), + "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), + other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))), + } +} + +/// Placeholder for tools whose behaviour hasn't landed yet (selecting them still works). +pub struct NoopTool(&'static str); + +impl Tool for NoopTool { + fn id(&self) -> &'static str { + self.0 + } + fn pointer(&mut self, _cx: &ToolContext, _ev: &PointerEvent) -> Vec { + vec![] + } +} + +pub(crate) fn json_ids(ids: &[NodeId]) -> Value { + Value::Array(ids.iter().map(|i| Value::from(i.0)).collect()) +} + +#[cfg(test)] +pub(crate) mod testutil { + use super::*; + use drawcraft_doc::{Appearance, Node}; + use drawcraft_geom::shapes; + + pub fn doc_with_rect() -> (Document, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())).unwrap(); + (d, id) + } + + pub fn paint() -> PaintDefaults { + PaintDefaults { fill: Paint::solid(drawcraft_color::Color::WHITE), stroke: Paint::solid(drawcraft_color::Color::BLACK), stroke_width: 1.0 } + } + + pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> { + ToolContext { + doc: d, + selection: s, + zoom: 1.0, + isolation: None, + paint: p, + outline: false, + smart_guides: true, + snap_to_grid: false, + show_bbox: true, + } + } +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs new file mode 100644 index 000000000..ac9b5a09e --- /dev/null +++ b/crates/tools/src/pen.rs @@ -0,0 +1,181 @@ +//! The Pen tool (P). +//! +//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag +//! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path. +//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues +//! it. The rubber-band preview shows the next segment. + +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::{BezPath, Point}; +use serde_json::json; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Default)] +pub struct PenTool { + /// The path being drawn (set once the first anchor exists and the engine selected it). + drawing: bool, + drag: Option<(Point, bool)>, + hover: Option, +} + +/// The open path the pen is extending: the single selected open path. +fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { + if cx.selection.objects.len() != 1 { + return None; + } + let id = cx.selection.objects[0]; + let n = cx.doc.node(id)?; + let NodeKind::Path { path, .. } = &n.kind else { return None }; + let sp = path.subpaths.last()?; + if sp.closed { + return None; + } + let first = sp.anchors.first()?.p; + let last = sp.anchors.last()?; + Some((id, first, last.p, last.h_out)) +} + +impl Tool for PenTool { + fn id(&self) -> &'static str { + "pen" + } + fn busy(&self) -> bool { + self.drag.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let mut p = ev.pos; + let tol = cx.tol(5.0); + let active = if self.drawing { active_path(cx) } else { None }; + if self.drawing && active.is_none() && ev.kind == PointerKind::Down { + self.drawing = false; + } + match ev.kind { + PointerKind::Move => { + self.hover = Some(p); + vec![] + } + PointerKind::Down => { + if let Some((id, first, last, _)) = active { + if ev.mods.shift { + p = last + drawcraft_geom::constrain_angle(p - last, 45.0); + } + if p.distance(first) <= tol { + self.drag = Some((first, true)); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + } + self.drag = Some((p, false)); + return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + } + // Continue a selected open path when clicking on one of its ends. + if let Some((_, first, last, _)) = active_path(cx) + && (p.distance(last) <= tol || p.distance(first) <= tol) + { + self.drawing = true; + if p.distance(first) <= tol && p.distance(last) > tol { + return vec![Action::Exec("path.reverse".into(), json!({}))]; + } + return vec![]; + } + self.drawing = true; + self.drag = Some((p, false)); + vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] + } + PointerKind::Drag => { + let Some((a, closing)) = self.drag else { return vec![] }; + let mut out_h = ev.pos; + if ev.mods.shift { + out_h = a + drawcraft_geom::constrain_angle(ev.pos - a, 45.0); + } + let alt = ev.mods.alt; + let Some((id, ..)) = active_path(cx).or(active) else { + // First anchor of a new path: re-issue create with handles. + let in_h = a - (out_h - a); + return vec![Action::Preview( + "path.create".into(), + json!({"anchors": [{"x": a.x, "y": a.y, "out": [out_h.x, out_h.y], "in": [in_h.x, in_h.y]}]}), + )]; + }; + if closing { + return vec![Action::Preview( + "path.close".into(), + json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}), + )]; + } + let in_h = if alt { a } else { a - (out_h - a) }; + vec![Action::Preview( + "path.appendAnchor".into(), + json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}), + )] + } + PointerKind::Up => { + let Some((_, closing)) = self.drag.take() else { return vec![] }; + if closing { + self.drawing = false; + } + vec![Action::Commit] + } + PointerKind::DoubleClick => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + match key { + ToolKey::Enter | ToolKey::Escape => { + self.drawing = false; + self.drag = None; + vec![] + } + _ => vec![], + } + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.drawing = false; + vec![] + } + fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.drawing || self.drag.is_some() { + return vec![]; + } + let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] }; + let mut bp = BezPath::new(); + bp.move_to(last); + if out.distance(last) > 1e-9 { + bp.quad_to(out, h); + } else { + bp.line_to(h); + } + let c = cx.doc.layer_color(id); + vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }] + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if let Some((_, first, last, _)) = active_path(cx) { + if self.drawing && p.distance(first) <= cx.tol(5.0) { + return Cursor::PenClose; + } + if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) { + return Cursor::PenContinue; + } + } + Cursor::Pen + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn first_click_creates_path() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(a[0], Action::Begin("Pen".into())); + assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); + } +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs new file mode 100644 index 000000000..bd2543587 --- /dev/null +++ b/crates/tools/src/select.rs @@ -0,0 +1,307 @@ +//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains), +//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), +//! double-click to enter isolation mode. + +use drawcraft_doc::NodeId; +use drawcraft_doc::hit::{hit_test, marquee}; +use drawcraft_geom::{Affine, Point, Rect}; +use serde_json::{Value, json}; + +use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids}; + +#[derive(Clone, Debug, Default)] +enum State { + #[default] + Idle, + /// Pressed on an object; becomes Moving after the drag threshold. + Moving { + start: Point, + began: bool, + }, + Scaling { + handle: Handle, + rect: Rect, + }, + Rotating { + center: Point, + start: Point, + }, + Marquee { + start: Point, + cur: Point, + add: bool, + }, +} + +#[derive(Default)] +pub struct SelectionTool { + state: State, + measure: Option<(Point, String)>, +} + +pub fn matrix_json(a: Affine) -> Value { + let c = a.as_coeffs(); + json!([c[0], c[1], c[2], c[3], c[4], c[5]]) +} + +/// Selection bounds used for the bounding box (geometric bounds, like Illustrator's default +/// "Use Preview Bounds" off). +pub fn selection_bounds(cx: &ToolContext) -> Option { + cx.doc.bounds_of(&cx.selection.objects, false) +} + +impl SelectionTool { + fn drag_threshold(cx: &ToolContext) -> f64 { + cx.tol(3.0) + } +} + +impl Tool for SelectionTool { + fn id(&self) -> &'static str { + "selection" + } + + fn busy(&self) -> bool { + !matches!(self.state, State::Idle) + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let m = ev.mods; + match (ev.kind, self.state.clone()) { + (PointerKind::DoubleClick, _) => { + self.state = State::Idle; + if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + let top = h.top_object(cx.isolation); + if cx.doc.node(top).is_some_and(|n| matches!(n.kind, drawcraft_doc::NodeKind::Group { .. })) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } + if cx.doc.node(top).is_some_and(|n| matches!(n.kind, drawcraft_doc::NodeKind::Text(_))) { + return vec![Action::SwitchTool("type".into())]; + } + } else if cx.isolation.is_some() { + return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; + } + vec![] + } + (PointerKind::Down, _) => { + // 1. Bounding-box handles of the current selection. + if cx.show_bbox + && let Some(r) = selection_bounds(cx) + { + let tol = cx.tol(5.0); + if let Some(h) = hit_handle(r, p, tol) { + self.state = State::Scaling { handle: h, rect: r }; + return vec![Action::Begin("Scale".into())]; + } + if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { + self.state = State::Rotating { center: r.center(), start: p }; + return vec![Action::Begin("Rotate".into())]; + } + } + // 2. Objects. + match hit_test(cx.doc, p, cx.hit_options()) { + Some(h) => { + let top = h.top_object(cx.isolation); + let mut out = vec![]; + if m.shift { + out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); + if cx.selection.contains(top) { + self.state = State::Idle; + return out; + } + } else if !cx.selection.contains(top) { + out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] }))); + } + self.state = State::Moving { start: p, began: false }; + out + } + None => { + self.state = State::Marquee { start: p, cur: p, add: m.shift }; + vec![] + } + } + } + (PointerKind::Drag, State::Moving { start, began, .. }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < Self::drag_threshold(cx) { + return out; + } + out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); + } + let d = move_delta(start, p, m.shift); + self.state = State::Moving { start, began: true }; + self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", d.x, d.y))); + out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); + out + } + (PointerKind::Drag, State::Scaling { handle, rect, .. }) => { + let a = scale_for_drag(rect, handle, p, m.shift, m.alt); + let nr = a.transform_rect_bbox(rect); + self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height()))); + vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] + } + (PointerKind::Drag, State::Rotating { center, start, .. }) => { + let (a, deg) = rotate_for_drag(center, start, p, m.shift); + self.measure = Some((p, format!("{:.1}°", -deg))); + vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] + } + (PointerKind::Drag, State::Marquee { start, add, .. }) => { + self.state = State::Marquee { start, cur: p, add }; + vec![] + } + (PointerKind::Up, State::Moving { began, .. }) => { + self.state = State::Idle; + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { + self.state = State::Idle; + self.measure = None; + vec![Action::Commit] + } + (PointerKind::Up, State::Marquee { start, add, .. }) => { + self.state = State::Idle; + let r = Rect::from_points(start, p); + if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { + return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + } + let ids: Vec = marquee(cx.doc, r, cx.isolation, false); + if add { + vec![Action::Exec("select.add".into(), json!({ "ids": json_ids(&ids) }))] + } else { + vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))] + } + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + let mut o = vec![]; + if let State::Marquee { start, cur, .. } = self.state { + o.push(Overlay::Marquee(Rect::from_points(start, cur))); + } + if let Some((p, t)) = &self.measure { + o.push(Overlay::Measure { p: *p, text: t.clone() }); + } + o + } + + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + match self.state { + State::Rotating { .. } => return Cursor::Rotate, + State::Scaling { handle, .. } => return handle_cursor(handle), + State::Moving { began: true, .. } => return Cursor::Arrow, + _ => {} + } + if cx.show_bbox + && let Some(r) = selection_bounds(cx) + { + let tol = cx.tol(5.0); + if let Some(h) = hit_handle(r, p, tol) { + return handle_cursor(h); + } + if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { + return Cursor::Rotate; + } + } + if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + if cx.selection.contains(h.top_object(cx.isolation)) || m.alt { + return Cursor::Move; + } + return Cursor::Arrow; + } + Cursor::Arrow + } +} + +fn handle_cursor(h: Handle) -> Cursor { + 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, + } +} + +#[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 click_selects_object() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty()); + } + + #[test] + fn drag_moves_with_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 = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0)); + assert_eq!(a, vec![Action::Commit]); + } + + #[test] + fn marquee_selects() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + assert_eq!(t.overlays(&cx).len(), 1); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + } + + #[test] + fn handle_drag_scales() { + 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 = SelectionTool::default(); + assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); + } + + #[test] + fn click_empty_deselects() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]); + } +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs new file mode 100644 index 000000000..e03df7e32 --- /dev/null +++ b/crates/tools/src/shape.rs @@ -0,0 +1,251 @@ +//! Shape tools: Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment. +//! +//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre); a click without a +//! drag asks the UI for the size dialog (like Illustrator). ↑/↓ during a polygon/star drag change the +//! side/point count. + +use drawcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +pub struct ShapeTool { + id: &'static str, + start: Option, + last: Point, + mods: Mods, + began: bool, + pub sides: u32, + pub points: u32, + pub corner_radius: f64, + /// Star inner radius as a fraction of the outer radius. + pub star_ratio: f64, +} + +impl ShapeTool { + pub fn new(id: &str) -> Self { + let id: &'static str = match id { + "roundedRectangle" => "roundedRectangle", + "ellipse" => "ellipse", + "polygon" => "polygon", + "star" => "star", + "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 } + } + + fn label(&self) -> &'static str { + match self.id { + "roundedRectangle" => "Rounded Rectangle", + "ellipse" => "Ellipse", + "polygon" => "Polygon", + "star" => "Star", + "lineSegment" => "Line", + _ => "Rectangle", + } + } + + /// The command for the current drag. + pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) { + match self.id { + "polygon" | "star" => { + let v = p - start; + let r = v.hypot().max(0.01); + let rot = if m.shift { 0.0 } else { (v.atan2().to_degrees() + 90.0) % 360.0 }; + if self.id == "polygon" { + ("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot })) + } else { + ( + "shape.star".into(), + json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * self.star_ratio, "points": self.points, "rotation": rot }), + ) + } + } + "lineSegment" => { + let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) }; + let b = if m.shift { a + drawcraft_geom::constrain_angle(b - a, 45.0) } else { b }; + ("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y })) + } + _ => { + let mut d = p - start; + if m.shift { + let s = d.x.abs().max(d.y.abs()); + d = drawcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum()); + } + let r = if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }; + let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" }; + let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }); + if self.id == "roundedRectangle" { + v["radius"] = json!(self.corner_radius); + } + (cmd.into(), v) + } + } + } +} + +impl Tool for ShapeTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.start.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + self.start = Some(ev.pos); + self.last = ev.pos; + 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 self.began { + self.began = false; + 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, _mods: Mods) -> Vec { + let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; + let changed = match (self.id, key) { + ("polygon", ToolKey::Up) => { + self.sides = (self.sides + 1).min(1000); + true + } + ("polygon", ToolKey::Down) => { + self.sides = self.sides.saturating_sub(1).max(3); + true + } + ("star", ToolKey::Up) => { + self.points = (self.points + 1).min(1000); + true + } + ("star", ToolKey::Down) => { + self.points = self.points.saturating_sub(1).max(3); + true + } + ("roundedRectangle", ToolKey::Up) => { + self.corner_radius += 1.0; + true + } + ("roundedRectangle", ToolKey::Down) => { + self.corner_radius = (self.corner_radius - 1.0).max(0.0); + true + } + _ => false, + }; + if changed { + let (c, v) = self.command(s, self.last, self.mods); + vec![Action::Preview(c, v)] + } else if key == ToolKey::Escape { + self.start = None; + self.began = false; + vec![Action::Cancel] + } else { + vec![] + } + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + match self.start { + Some(s) if self.began => { + let d = self.last - s; + vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }] + } + _ => vec![], + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + json!({ "sides": self.sides, "points": self.points, "cornerRadius": self.corner_radius, "starRatio": self.star_ratio }) + } + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "sides" => self.sides = v.as_u64().unwrap_or(6).clamp(3, 1000) as u32, + "points" => self.points = v.as_u64().unwrap_or(5).clamp(3, 1000) as u32, + "cornerRadius" => self.corner_radius = v.as_f64().unwrap_or(12.0).max(0.0), + "starRatio" => self.star_ratio = v.as_f64().unwrap_or(0.5).clamp(0.01, 1.0), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn rect_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 30.0)); + assert_eq!(a[0], Action::Begin("Rectangle".into())); + assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]); + } + + #[test] + fn shift_square_alt_center() { + let t = ShapeTool::new("ellipse"); + let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, alt: true, ..Default::default() }); + assert_eq!(c, "shape.ellipse"); + assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0})); + } + + #[test] + fn click_opens_dialog() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("star"); + 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("star".into(), json!({"x": 10.0, "y": 10.0}))] + ); + } + + #[test] + fn arrow_keys_change_sides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("polygon"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); + let a = t.key(&cx, ToolKey::Up, Mods::default()); + assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7)); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 000000000..03a0868f5 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "drawcraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 000000000..1209a8580 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1 @@ +//! stub diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..3121924d6 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 000000000..df7dfa877 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 000000000..f328e4d9d --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1 @@ +fn main() {}