From f7a68c864b0f189585dd561b81f604d302bf98a0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 12:01:57 -0400 Subject: [PATCH] M0.1-M0.3: workspace, licences, geom/color/doc crates Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 4 + .gitignore | 7 + Cargo.lock | 215 +++++++++ Cargo.toml | 79 ++++ LICENSE-APACHE | 177 ++++++++ LICENSE-MIT | 21 + NOTICE | 21 + clippy.toml | 5 + contributors/people.toml | 18 + crates/color/Cargo.toml | 16 + crates/color/src/blend.rs | 107 +++++ crates/color/src/hsx.rs | 100 +++++ crates/color/src/lib.rs | 392 ++++++++++++++++ crates/color/src/tests.rs | 67 +++ crates/doc/Cargo.toml | 16 + crates/doc/src/geometry.rs | 220 +++++++++ crates/doc/src/lib.rs | 689 +++++++++++++++++++++++++++++ crates/doc/src/node.rs | 429 ++++++++++++++++++ crates/doc/src/props.rs | 463 +++++++++++++++++++ crates/doc/src/proto.rs | 264 +++++++++++ crates/doc/src/styles.rs | 166 +++++++ crates/doc/src/tests.rs | 253 +++++++++++ crates/doc/src/text.rs | 383 ++++++++++++++++ crates/doc/src/vector.rs | 314 +++++++++++++ crates/geom/Cargo.toml | 13 + crates/geom/src/lib.rs | 324 ++++++++++++++ crates/geom/src/tests.rs | 102 +++++ crates/geom/src/transform.rs | 61 +++ docs/brand/LICENSE-brand.txt | 42 ++ docs/brand/artcraft-logo-white.png | Bin 0 -> 32161 bytes docs/brand/artcraft-logo-white.svg | 7 + docs/brand/artcraft-logo.png | Bin 0 -> 31302 bytes docs/brand/artcraft-logo.svg | 7 + docs/brand/artcraft-mark-black.png | Bin 0 -> 26793 bytes docs/brand/artcraft-mark-black.svg | 4 + docs/brand/artcraft-mark.png | Bin 0 -> 24715 bytes docs/brand/artcraft-mark.svg | 4 + rustfmt.toml | 2 + xtask/Cargo.toml | 9 + xtask/src/main.rs | 1 + 40 files changed, 5002 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 NOTICE create mode 100644 clippy.toml create mode 100644 contributors/people.toml create mode 100644 crates/color/Cargo.toml create mode 100644 crates/color/src/blend.rs create mode 100644 crates/color/src/hsx.rs create mode 100644 crates/color/src/lib.rs create mode 100644 crates/color/src/tests.rs create mode 100644 crates/doc/Cargo.toml create mode 100644 crates/doc/src/geometry.rs create mode 100644 crates/doc/src/lib.rs create mode 100644 crates/doc/src/node.rs create mode 100644 crates/doc/src/props.rs create mode 100644 crates/doc/src/proto.rs create mode 100644 crates/doc/src/styles.rs create mode 100644 crates/doc/src/tests.rs create mode 100644 crates/doc/src/text.rs create mode 100644 crates/doc/src/vector.rs create mode 100644 crates/geom/Cargo.toml create mode 100644 crates/geom/src/lib.rs create mode 100644 crates/geom/src/tests.rs create mode 100644 crates/geom/src/transform.rs create mode 100644 docs/brand/LICENSE-brand.txt create mode 100644 docs/brand/artcraft-logo-white.png create mode 100644 docs/brand/artcraft-logo-white.svg create mode 100644 docs/brand/artcraft-logo.png create mode 100644 docs/brand/artcraft-logo.svg create mode 100644 docs/brand/artcraft-mark-black.png create mode 100644 docs/brand/artcraft-mark-black.svg create mode 100644 docs/brand/artcraft-mark.png create mode 100644 docs/brand/artcraft-mark.svg create mode 100644 rustfmt.toml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..541c8523 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Local builds pick up the optional craft-fonts checkout next to this repo (Inter etc.). When the +# directory is missing the build warns and carries on with egui's bundled fonts and system fonts. +[env] +CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } diff --git a/.gitignore b/.gitignore index 0807ae54..928d6b49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *~ plan/ plans/ +/target/ +/target-*/ +/dist/ +/corpus/ +/log/ +.DS_Store +*.profraw diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..6dc0f418 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,215 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "figcraft-color" +version = "0.1.0" +dependencies = [ + "kurbo", + "serde", + "serde_json", +] + +[[package]] +name = "figcraft-doc" +version = "0.1.0" +dependencies = [ + "figcraft-color", + "figcraft-geom", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "figcraft-geom" +version = "0.1.0" +dependencies = [ + "kurbo", + "serde", +] + +[[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 = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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", +] + +[[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 = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[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 = "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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..22e48fc5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,79 @@ +[workspace] +resolver = "3" +members = ["crates/*", "xtask"] +# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.95" +repository = "https://github.com/storytold/figcraft" + +[workspace.dependencies] +# internal +figcraft-geom = { path = "crates/geom" } +figcraft-color = { path = "crates/color" } +figcraft-doc = { path = "crates/doc" } +figcraft-layout = { path = "crates/layout" } +figcraft-text = { path = "crates/text" } +figcraft-pathops = { path = "crates/pathops" } +figcraft-render = { path = "crates/render" } +figcraft-svg = { path = "crates/svg" } +figcraft-pdf = { path = "crates/pdf" } +figcraft-format = { path = "crates/format" } +figcraft-tools = { path = "crates/tools" } +figcraft-engine = { path = "crates/engine" } +figcraft-ui-egui = { path = "crates/ui-egui" } +figcraft-mcp = { path = "crates/mcp" } + +# foundation +kurbo = { version = "0.13", features = ["serde"] } +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +proptest = "1" + +# rendering / formats +tiny-skia = "0.12" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] } +usvg = "0.48" +krilla = "0.8" +skrifa = "0.47" +harfrust = "0.13" +linesweeper = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } +sha2 = "0.10" +base64 = "0.22" + +# ui +egui = "0.36" +eframe = { version = "0.36", default-features = false } +rfd = "0.17" +arboard = { version = "3.6", default-features = false } +webbrowser = "1" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +# FigCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see +# AGENTS.md › Never crash). Tests may unwrap (clippy.toml). +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unreachable = "deny" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +lto = "thin" +codegen-units = 4 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..0a90804b --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the FigCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..f50a4b7a --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +FigCraft +Copyright (c) 2026 ArtCraft Team and the FigCraft contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are +not open source and are not covered by the license above; their terms are in +docs/brand/LICENSE-brand.txt. + +Third-party material + +This product bundles no third-party fonts, icons or images in the repository: every icon is +drawn in code and original to FigCraft. The per-file list (author, source, license) of any bundled +asset is in ATTRIBUTION.md. + +Release builds made with the optional craft-fonts build input (CRAFT_FONTS_DIR) embed the fonts +listed in ATTRIBUTION.md (Inter and others, SIL Open Font License 1.1); each package carries their +licence texts. + +Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, Zlib, OFL for egui's default +fonts and similar permissive licenses); see each crate's metadata. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..0369326b --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +# Tests may unwrap, expect and panic: a failing assertion is the point of a test. Shipped code may +# not (workspace lints in Cargo.toml; AGENTS.md › Robustness). +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true diff --git a/contributors/people.toml b/contributors/people.toml new file mode 100644 index 00000000..e61cfd31 --- /dev/null +++ b/contributors/people.toml @@ -0,0 +1,18 @@ +# Contributor names for the About window (craftrules standards/contributors.md). +# +# Everyone who contributes is credited by GitHub username automatically. Anything more is up to +# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a +# maintainer may add a person only when that person asked, recording where in consent_source. +# +# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ +# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] +# +# Or copy this block, replacing your-github-username (every field is optional): +# +# [people.your-github-username] +# real_name = "Your Name" # shown when the viewer picks "Real name" +# display_name = "Nickname" # shown when the viewer picks "Display name" +# sync_github_name = true # or: use the name on your public GitHub profile as display name +# +# To remove your names, delete your entry (or set fields empty) in a PR. The next +# contributors.py run, and the next build, drop them. diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 00000000..0bd58852 --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "figcraft-color" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +kurbo = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs new file mode 100644 index 00000000..51e4ca52 --- /dev/null +++ b/crates/color/src/blend.rs @@ -0,0 +1,107 @@ +//! Layer and paint blend modes (the list Figma's blend menu shows). + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum BlendMode { + /// Layers only: children blend with what's behind the group (Figma's default for groups/frames). + PassThrough, + #[default] + Normal, + Darken, + Multiply, + PlusDarker, + ColorBurn, + Lighten, + Screen, + PlusLighter, + ColorDodge, + Overlay, + SoftLight, + HardLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// Every mode in menu order, with separators implied between groups. + pub const ALL: [BlendMode; 19] = [ + BlendMode::PassThrough, + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::PlusDarker, + BlendMode::ColorBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::PlusLighter, + 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::PassThrough => "Pass through", + BlendMode::Normal => "Normal", + BlendMode::Darken => "Darken", + BlendMode::Multiply => "Multiply", + BlendMode::PlusDarker => "Plus darker", + BlendMode::ColorBurn => "Color burn", + BlendMode::Lighten => "Lighten", + BlendMode::Screen => "Screen", + BlendMode::PlusLighter => "Plus lighter", + 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", + } + } + + /// The CSS `mix-blend-mode` keyword (for SVG/CSS export). + pub fn css(self) -> &'static str { + match self { + BlendMode::PassThrough | BlendMode::Normal => "normal", + BlendMode::Darken => "darken", + BlendMode::Multiply => "multiply", + BlendMode::PlusDarker => "plus-darker", + BlendMode::ColorBurn => "color-burn", + BlendMode::Lighten => "lighten", + BlendMode::Screen => "screen", + BlendMode::PlusLighter => "plus-lighter", + 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", + } + } + + pub fn from_name(s: &str) -> Option { + let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key || m.label().replace(' ', "").to_ascii_lowercase() == key) + } +} diff --git a/crates/color/src/hsx.rs b/crates/color/src/hsx.rs new file mode 100644 index 00000000..a19a5fd5 --- /dev/null +++ b/crates/color/src/hsx.rs @@ -0,0 +1,100 @@ +//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex. + +use crate::Color; +use serde::{Deserialize, Serialize}; + +/// Hue 0..360, saturation and brightness 0..1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Hsb { + pub h: f32, + pub s: f32, + pub b: f32, +} + +/// Hue 0..360, saturation and lightness 0..1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Hsl { + pub h: f32, + pub s: f32, + pub l: f32, +} + +fn wrap_hue(h: f32) -> f32 { + if !h.is_finite() { + return 0.0; + } + let h = h % 360.0; + if h < 0.0 { h + 360.0 } else { h } +} + +fn c01(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +impl Hsb { + pub fn from_color(c: Color) -> Self { + let (r, g, b) = (c.r, c.g, c.b); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = hue(r, g, b, max, d); + let s = if max <= 0.0 { 0.0 } else { d / max }; + Self { h, s, b: max } + } + + pub fn to_color(self) -> Color { + let (h, s, v) = (wrap_hue(self.h), c01(self.s), c01(self.b)); + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + let (r, g, b) = sector(h, c, x); + Color::new(r + m, g + m, b + m, 1.0) + } +} + +impl Hsl { + pub fn from_color(c: Color) -> Self { + let (r, g, b) = (c.r, c.g, c.b); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let l = (max + min) / 2.0; + let h = hue(r, g, b, max, d); + let s = if d <= 0.0 { 0.0 } else { d / (1.0 - (2.0 * l - 1.0).abs()).max(1e-6) }; + Self { h, s: c01(s), l } + } + + pub fn to_color(self) -> Color { + let (h, s, l) = (wrap_hue(self.h), c01(self.s), c01(self.l)); + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = l - c / 2.0; + let (r, g, b) = sector(h, c, x); + Color::new(r + m, g + m, b + m, 1.0) + } +} + +fn hue(r: f32, g: f32, b: f32, max: f32, d: f32) -> f32 { + if d <= 0.0 { + return 0.0; + } + let h = if (max - r).abs() < f32::EPSILON { + 60.0 * (((g - b) / d) % 6.0) + } else if (max - g).abs() < f32::EPSILON { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + wrap_hue(h) +} + +fn sector(h: f32, c: f32, x: f32) -> (f32, f32, f32) { + match (h / 60.0) 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), + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 00000000..16fede06 --- /dev/null +++ b/crates/color/src/lib.rs @@ -0,0 +1,392 @@ +//! FigCraft colour and paint. +//! +//! [`Color`] is non-premultiplied sRGB in `0..=1` floats (what a design panel shows as hex + opacity). +//! [`Paint`] is one entry of a node's fill or stroke list: solid, the four gradient kinds or an +//! image, each with its own opacity, visibility and blend mode. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use serde::{Deserialize, Serialize}; + +mod blend; +mod hsx; +pub use blend::BlendMode; +pub use hsx::*; + +/// A non-premultiplied sRGB colour with alpha, components in `0..=1`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Color { + pub r: f32, + pub g: f32, + pub b: f32, + #[serde(default = "one")] + pub a: f32, +} + +fn one() -> f32 { + 1.0 +} + +fn clamp01(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +impl Default for Color { + fn default() -> Self { + Color::BLACK + } +} + +impl Color { + pub const BLACK: Color = Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + pub const WHITE: Color = Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + pub const TRANSPARENT: Color = Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; + /// Figma's default shape fill (#D9D9D9). + pub const DEFAULT_SHAPE: Color = Color::from_rgb8_const(0xD9, 0xD9, 0xD9); + /// Default page background (#F5F5F5). + pub const DEFAULT_PAGE: Color = Color::from_rgb8_const(0xF5, 0xF5, 0xF5); + + pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) } + } + + pub const fn from_rgb8_const(r: u8, g: u8, b: u8) -> Self { + Self { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 } + } + + pub fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: f32::from(a) / 255.0 } + } + + pub fn to_rgba8(self) -> [u8; 4] { + let q = |v: f32| (clamp01(v) * 255.0).round() as u8; + [q(self.r), q(self.g), q(self.b), q(self.a)] + } + + pub fn with_alpha(self, a: f32) -> Self { + Self { a: clamp01(a), ..self } + } + + /// Clamp every component into range and replace NaN (untrusted input). + pub fn sanitized(self) -> Self { + Self::new(self.r, self.g, self.b, self.a) + } + + /// `RRGGBB` (upper-case, no `#`), the way the design panel shows colours. + pub fn to_hex(self) -> String { + let [r, g, b, _] = self.to_rgba8(); + format!("{r:02X}{g:02X}{b:02X}") + } + + /// `#RRGGBB` or `#RRGGBBAA` when not opaque. + pub fn to_css_hex(self) -> String { + let [r, g, b, a] = self.to_rgba8(); + if a == 255 { format!("#{r:02X}{g:02X}{b:02X}") } else { format!("#{r:02X}{g:02X}{b:02X}{a:02X}") } + } + + /// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits + /// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`). + pub fn from_hex(s: &str) -> Option { + let s = s.trim().trim_start_matches('#'); + if !s.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let digits: Vec = s.chars().filter_map(|c| c.to_digit(16)).map(|d| d as u8).collect(); + let byte = |hi: u8, lo: u8| hi * 16 + lo; + let (r, g, b, a) = match digits.as_slice() { + [d] => (byte(*d, *d), byte(*d, *d), byte(*d, *d), 255), + [a, b] => (byte(*a, *b), byte(*a, *b), byte(*a, *b), 255), + [r, g, b] => (byte(*r, *r), byte(*g, *g), byte(*b, *b), 255), + [r, g, b, a] => (byte(*r, *r), byte(*g, *g), byte(*b, *b), byte(*a, *a)), + [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2), byte(*g1, *g2), byte(*b1, *b2), 255), + [r1, r2, g1, g2, b1, b2, a1, a2] => (byte(*r1, *r2), byte(*g1, *g2), byte(*b1, *b2), byte(*a1, *a2)), + _ => return None, + }; + Some(Self::from_rgba8(r, g, b, a)) + } + + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names. + pub fn parse_css(s: &str) -> Option { + let t = s.trim().to_ascii_lowercase(); + if t.starts_with('#') { + return Self::from_hex(&t); + } + if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) { + let parts = css_args(inner); + let num = |i: usize| -> Option { + let p = parts.get(i)?; + if let Some(pc) = p.strip_suffix('%') { pc.trim().parse::().ok().map(|v| v / 100.0) } else { p.parse::().ok().map(|v| v / 255.0) } + }; + let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); + } + if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { + let parts = css_args(inner); + let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; + let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; + let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; + let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); + } + let named = match t.as_str() { + "black" => Color::BLACK, + "white" => Color::WHITE, + "transparent" => Color::TRANSPARENT, + "red" => Color::from_rgb8_const(255, 0, 0), + "green" => Color::from_rgb8_const(0, 128, 0), + "lime" => Color::from_rgb8_const(0, 255, 0), + "blue" => Color::from_rgb8_const(0, 0, 255), + "yellow" => Color::from_rgb8_const(255, 255, 0), + "orange" => Color::from_rgb8_const(255, 165, 0), + "purple" => Color::from_rgb8_const(128, 0, 128), + "gray" | "grey" => Color::from_rgb8_const(128, 128, 128), + _ => return Self::from_hex(&t), + }; + Some(named) + } + + /// CSS `rgba(...)` / hex text for copy-as-CSS. + pub fn to_css(self) -> String { + let [r, g, b, _] = self.to_rgba8(); + if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) } + } + + /// Linear interpolation in sRGB (as gradients render). + pub fn lerp(self, other: Color, t: f32) -> Color { + let t = clamp01(t); + Color::new(self.r + (other.r - self.r) * t, self.g + (other.g - self.g) * t, self.b + (other.b - self.b) * t, self.a + (other.a - self.a) * t) + } + + /// Relative luminance (WCAG). + pub fn luminance(self) -> f32 { + let lin = |c: f32| if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }; + 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b) + } + + /// WCAG contrast ratio between two opaque colours. + pub fn contrast(self, other: Color) -> f32 { + let (a, b) = (self.luminance() + 0.05, other.luminance() + 0.05); + if a > b { a / b } else { b / a } + } +} + +fn css_args(inner: &str) -> Vec { + inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect() +} + +fn parse_alpha(a: &str) -> Option { + if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() } +} + +/// One colour stop of a gradient. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ColorStop { + /// Position along the gradient, 0..=1. + pub position: f32, + pub color: Color, +} + +/// The gradient kinds Figma offers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum GradientKind { + #[default] + Linear, + Radial, + Angular, + Diamond, +} + +/// A gradient paint. The three handles are in the node's unit box (0..1 on both axes): `from` +/// is the start (centre for radial/angular/diamond), `to` the end of the main axis, `width` the end +/// of the secondary axis (ellipse width for radial). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gradient { + pub kind: GradientKind, + pub stops: Vec, + pub from: (f64, f64), + pub to: (f64, f64), + pub width: (f64, f64), +} + +impl Gradient { + /// Figma's default gradient for `kind`: black → transparent black? No: the default stop pair is + /// the current colour at 100% → the same at 0% opacity. We use black→white for a visible default. + pub fn default_for(kind: GradientKind, base: Color) -> Self { + let (from, to, width) = match kind { + GradientKind::Linear => ((0.5, 0.0), (0.5, 1.0), (0.0, 0.5)), + _ => ((0.5, 0.5), (0.5, 1.0), (1.0, 0.5)), + }; + Self { + kind, + stops: vec![ColorStop { position: 0.0, color: base.with_alpha(1.0) }, ColorStop { position: 1.0, color: base.with_alpha(0.0) }], + from, + to, + width, + } + } + + /// Stops sorted by position with NaNs removed; at least one stop. + pub fn sorted_stops(&self) -> Vec { + let mut s: Vec = + self.stops.iter().filter(|s| s.position.is_finite()).map(|s| ColorStop { position: clamp01(s.position), color: s.color.sanitized() }).collect(); + s.sort_by(|a, b| a.position.total_cmp(&b.position)); + if s.is_empty() { + s.push(ColorStop { position: 0.0, color: Color::BLACK }); + } + s + } + + /// The colour at `t` (0..1) along the gradient. + pub fn sample(&self, t: f32) -> Color { + let stops = self.sorted_stops(); + let t = clamp01(t); + let mut prev = match stops.first() { + Some(s) => *s, + None => return Color::BLACK, + }; + if t <= prev.position { + return prev.color; + } + for s in stops.iter().skip(1) { + if t <= s.position { + let span = s.position - prev.position; + let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 }; + return prev.color.lerp(s.color, f); + } + prev = *s; + } + prev.color + } +} + +/// How an image fills its node. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ImageScaleMode { + #[default] + Fill, + Fit, + Crop, + Tile, +} + +/// Image adjustments (all `-1..=1`, 0 = unchanged). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ImageFilters { + pub exposure: f32, + pub contrast: f32, + pub saturation: f32, + pub temperature: f32, + pub tint: f32, + pub highlights: f32, + pub shadows: f32, +} + +impl ImageFilters { + pub fn is_identity(&self) -> bool { + *self == ImageFilters::default() + } +} + +/// An image paint: the image is referenced by its content hash in the document's image store. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImagePaint { + pub image: String, + #[serde(default)] + pub scale_mode: ImageScaleMode, + /// Tile scale (Tile mode), 1 = the image's own size. + #[serde(default = "one64")] + pub scale: f64, + /// Rotation in multiples of 90°. + #[serde(default)] + pub rotation: i32, + /// Crop transform (Crop mode): the unit-box → image-uv mapping as a 2×3 matrix. + #[serde(default)] + pub crop: Option<[f64; 6]>, + #[serde(default)] + pub filters: ImageFilters, +} + +fn one64() -> f64 { + 1.0 +} + +/// What a paint paints with. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PaintKind { + Solid { color: Color }, + Gradient(Gradient), + Image(ImagePaint), +} + +/// One fill or stroke paint. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Paint { + #[serde(flatten)] + pub kind: PaintKind, + #[serde(default = "one")] + pub opacity: f32, + #[serde(default = "yes")] + pub visible: bool, + #[serde(default)] + pub blend: BlendMode, +} + +fn yes() -> bool { + true +} + +impl Paint { + pub fn solid(color: Color) -> Self { + Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal } + } + + pub fn gradient(g: Gradient) -> Self { + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal } + } + + pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { + Self { + kind: PaintKind::Image(ImagePaint { image: hash.into(), scale_mode: mode, scale: 1.0, rotation: 0, crop: None, filters: ImageFilters::default() }), + opacity: 1.0, + visible: true, + blend: BlendMode::Normal, + } + } + + /// The solid colour (with the paint opacity folded into alpha), if solid. + pub fn solid_color(&self) -> Option { + match &self.kind { + PaintKind::Solid { color } => Some(color.with_alpha(color.a * self.opacity)), + _ => None, + } + } + + /// A short label for the panel row ("D9D9D9", "Linear", "Image"). + pub fn label(&self) -> String { + match &self.kind { + PaintKind::Solid { color } => color.to_hex(), + PaintKind::Gradient(g) => match g.kind { + GradientKind::Linear => "Linear".into(), + GradientKind::Radial => "Radial".into(), + GradientKind::Angular => "Angular".into(), + GradientKind::Diamond => "Diamond".into(), + }, + PaintKind::Image(_) => "Image".into(), + } + } + + /// A representative colour (for swatches and outlines). + pub fn preview_color(&self) -> Color { + match &self.kind { + PaintKind::Solid { color } => color.with_alpha(color.a * self.opacity), + PaintKind::Gradient(g) => g.sample(0.0), + PaintKind::Image(_) => Color::from_rgb8_const(0xB3, 0xB3, 0xB3), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs new file mode 100644 index 00000000..2f05db31 --- /dev/null +++ b/crates/color/src/tests.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn hex_roundtrip_and_shorthands() { + assert_eq!(Color::from_hex("#0D99FF").unwrap().to_hex(), "0D99FF"); + assert_eq!(Color::from_hex("f").unwrap().to_hex(), "FFFFFF"); + assert_eq!(Color::from_hex("ab").unwrap().to_hex(), "ABABAB"); + assert_eq!(Color::from_hex("abc").unwrap().to_hex(), "AABBCC"); + assert_eq!(Color::from_hex("FF000080").unwrap().to_rgba8(), [255, 0, 0, 128]); + assert!(Color::from_hex("xyz").is_none()); + assert!(Color::from_hex("12345").is_none()); +} + +#[test] +fn css_parsing() { + assert_eq!(Color::parse_css("rgb(255, 0, 0)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]); + assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00"); + assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE); +} + +#[test] +fn hsb_roundtrip() { + for hex in ["0D99FF", "F24822", "14AE5C", "000000", "FFFFFF", "808080", "9747FF"] { + let c = Color::from_hex(hex).unwrap(); + assert_eq!(Hsb::from_color(c).to_color().to_hex(), hex); + assert_eq!(Hsl::from_color(c).to_color().to_hex(), hex); + } +} + +#[test] +fn gradient_sampling() { + let g = Gradient { + kind: GradientKind::Linear, + stops: vec![ColorStop { position: 1.0, color: Color::WHITE }, ColorStop { position: 0.0, color: Color::BLACK }], + from: (0.0, 0.0), + to: (1.0, 0.0), + width: (0.0, 1.0), + }; + assert_eq!(g.sample(0.0), Color::BLACK); + assert_eq!(g.sample(1.0), Color::WHITE); + assert_eq!(g.sample(0.5).to_hex(), "808080"); +} + +#[test] +fn paint_serde_shape() { + let p = Paint::solid(Color::from_hex("FF0000").unwrap()); + let j = serde_json::to_value(&p).unwrap(); + assert_eq!(j["type"], "solid"); + let back: Paint = serde_json::from_value(j).unwrap(); + assert_eq!(back, p); + let g: Paint = serde_json::from_str(r#"{"type":"gradient","kind":"radial","stops":[],"from":[0.5,0.5],"to":[1,0.5],"width":[0.5,1]}"#).unwrap(); + assert!(matches!(g.kind, PaintKind::Gradient(_))); +} + +#[test] +fn blend_names() { + assert_eq!(BlendMode::from_name("multiply"), Some(BlendMode::Multiply)); + assert_eq!(BlendMode::from_name("Color burn"), Some(BlendMode::ColorBurn)); + assert_eq!(BlendMode::from_name("PASS_THROUGH"), Some(BlendMode::PassThrough)); +} + +#[test] +fn nan_inputs_are_sanitized() { + let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY); + assert_eq!(c.to_rgba8(), [0, 255, 0, 0]); +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 00000000..af5409d8 --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "figcraft-doc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "FigCraft document model: pages, the node tree, properties, components, styles, variables, prototype data" + +[lints] +workspace = true + +[dependencies] +figcraft-geom = { workspace = true } +figcraft-color = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs new file mode 100644 index 00000000..bb2281d6 --- /dev/null +++ b/crates/doc/src/geometry.rs @@ -0,0 +1,220 @@ +//! Node geometry: local outline paths, and hit testing with Figma's click-target rules. + +use crate::{Document, Node, NodeId, NodeKind}; +use figcraft_geom::{BezPath, Point, Rect, Shape}; + +/// The node's outline in its local space (`None` for groups, booleans, text and pages, whose +/// geometry comes from children or glyphs). +pub fn shape_path(n: &Node) -> Option { + let (w, h) = (n.width, n.height); + let c = &n.corners; + Some(match &n.kind { + NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { + figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing) + } + NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), + NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), + NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), + NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]), + NodeKind::Line => { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((w, 0.0)); + p + } + NodeKind::Vector { network } => { + let f = network.fill_path(); + if f.elements().is_empty() { network.stroke_path() } else { f } + } + NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), + NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None, + }) +} + +/// The open/closed path strokes follow (vector networks stroke every segment, filled or not). +pub fn stroke_geometry(n: &Node) -> Option { + match &n.kind { + NodeKind::Vector { network } => Some(network.stroke_path()), + _ => shape_path(n), + } +} + +/// Whether the shape's path is closed (fills apply). +pub fn is_closed(n: &Node) -> bool { + match &n.kind { + NodeKind::Line => false, + NodeKind::Vector { network } => !network.regions.is_empty(), + _ => true, + } +} + +fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool { + let inv = doc.world_transform(id).inverse(); + let lp = inv * pt; + // Tolerance in local units (approximate under scale). + let tol_l = tol; + match &n.kind { + NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, + NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), + NodeKind::Line => { + let half = (n.stroke.weight / 2.0).max(tol_l); + lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half + } + NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { + n.local_rect().inflate(tol_l, tol_l).contains(lp) + } + _ => { + let Some(path) = shape_path(n) else { return false }; + if is_closed(n) && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) && path.winding(lp) != 0 { + return true; + } + // Near the outline. + let reach = (n.stroke.weight / 2.0).max(tol_l); + if !path.bounding_box().inflate(reach, reach).contains(lp) { + return false; + } + near_path(&path, lp, reach) + } + } +} + +fn near_path(path: &BezPath, p: Point, dist: f64) -> bool { + use figcraft_geom::kurbo::ParamCurveNearest; + path.segments().any(|s| s.nearest(p, 0.01).distance_sq <= dist * dist) +} + +/// The innermost visible, unlocked node under `pt` (page coordinates), honouring clip content. +pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option { + let p = doc.node(page)?; + for c in p.children.iter().rev() { + if let Some(h) = hit_rec(doc, *c, pt, tol, 0) { + return Some(h); + } + } + None +} + +fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Option { + if depth > 512 { + return None; + } + let n = doc.node(id)?; + if !n.visible || n.locked { + return None; + } + if let NodeKind::Section { contents_hidden: true } = n.kind { + return hits_node_itself(doc, id, n, pt, tol).then_some(id); + } + let clips = n.clip && n.kind.is_frame_like(); + if clips && !hits_node_itself(doc, id, n, pt, 0.0) { + return None; + } + if matches!(n.kind, NodeKind::BooleanOp { .. }) { + // A boolean group is hit as one shape (approximated by any child hit). + for c in n.children.iter().rev() { + if hit_rec(doc, *c, pt, tol, depth + 1).is_some() { + return Some(id); + } + } + return None; + } + for c in n.children.iter().rev() { + if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1) { + return Some(h); + } + } + if hits_node_itself(doc, id, n, pt, tol) { + return Some(id); + } + None +} + +/// Resolve a clicked leaf to what a plain click selects. +/// +/// Figma's rules: top-level frames behave like artboards, so a click selects their direct child; +/// groups select as a whole; when something is already selected, its siblings (and their +/// contents) resolve relative to that context, which is how double-click "enters" groups. +/// `deep` (Cmd-click) selects the leaf itself. +pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: bool) -> NodeId { + if deep { + return leaf; + } + // Chain from the page's child down to the leaf. + let mut chain: Vec = doc.ancestors(leaf); + chain.reverse(); + chain.push(leaf); + // chain[0] is the page. + let chain: Vec = chain.into_iter().skip(1).collect(); + let Some(top) = chain.first().copied() else { return leaf }; + // Context: if a selected node's parent is in the chain, select that parent's child in the chain. + let mut best: Option = None; + for s in context { + let Some(parent) = doc.node(*s).and_then(|n| n.parent) else { continue }; + if let Some(pos) = chain.iter().position(|c| *c == parent) + && pos + 1 < chain.len() + { + best = Some(best.map_or(pos + 1, |b: usize| b.max(pos + 1))); + } + } + if let Some(i) = best + && let Some(id) = chain.get(i) + { + return *id; + } + let top_is_board = doc.node(top).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + if top_is_board && chain.len() > 1 { + // Sections contain frames: the frame inside a section is the board. + let is_section = |i: usize| chain.get(i).and_then(|c| doc.node(*c)).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })); + let is_board = |i: usize| chain.get(i).and_then(|c| doc.node(*c)).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + let mut i = 1; + while is_section(i - 1) && is_board(i) && i + 1 < chain.len() { + i += 1; + } + return chain.get(i).copied().unwrap_or(leaf); + } + top +} + +/// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the +/// children of top-level frames when the marquee starts inside a frame (`within`). +pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option) -> Vec { + let parent = within.unwrap_or(page); + let Some(p) = doc.node(parent) else { return vec![] }; + p.children + .iter() + .copied() + .filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)) + .filter(|c| { + doc.world_bounds(*c).is_some_and(|b| { + let inter = b.intersect(rect); + inter.width() > 0.0 || inter.height() > 0.0 || (b.width() == 0.0 || b.height() == 0.0) && rect.contains(b.origin()) + }) + }) + .collect() +} + +/// Figma marquee rule: a marquee that fully contains a top-level frame selects the frame; one +/// starting on empty canvas selects top-level objects it touches. +pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option) -> Vec { + if let Some(frame) = start_inside { + let inside = marquee(doc, page, rect, Some(frame)); + return inside; + } + marquee(doc, page, rect, None) +} + +/// Bounds of a node's outline in page coordinates for snapping (the box corners and centre). +pub fn snap_points(doc: &Document, id: NodeId) -> Vec { + let Some(b) = doc.world_bounds(id) else { return vec![] }; + vec![ + Point::new(b.x0, b.y0), + Point::new(b.x1, b.y0), + Point::new(b.x1, b.y1), + Point::new(b.x0, b.y1), + b.center(), + Point::new(b.center().x, b.y0), + Point::new(b.center().x, b.y1), + Point::new(b.x0, b.center().y), + Point::new(b.x1, b.center().y), + ] +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 00000000..18d1a935 --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,689 @@ +//! The FigCraft document model. +//! +//! A [`Document`] holds pages; each page is the root of a tree of [`Node`]s (frames, groups, shapes, +//! text, components, instances…). Nodes are stored by id in a map of `Arc`s, so cloning a document +//! (an undo snapshot) only copies pointers, and editing one node copies only that node. +//! +//! Everything here is pure data plus queries and structural edits; commands, history and rendering +//! live in higher crates. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod geometry; +pub mod node; +pub mod props; +pub mod proto; +pub mod styles; +pub mod text; +pub mod vector; + +pub use figcraft_color as color; +pub use figcraft_geom as geom; +pub use node::*; +pub use props::*; +pub use styles::*; +pub use text::*; +pub use vector::*; + +use figcraft_color::Color; +use figcraft_geom::{Affine, Point, Rect}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Stable node identifier, unique within a document. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, 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) + } +} + +impl std::str::FromStr for NodeId { + type Err = std::num::ParseIntError; + fn from_str(s: &str) -> Result { + // Accept "12" and Figma-style "0:12". + let t = s.trim(); + let t = t.rsplit(':').next().unwrap_or(t); + t.parse::().map(NodeId) + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum DocError { + #[error("no such node {0}")] + NoNode(NodeId), + #[error("{0} can't contain children")] + NotContainer(NodeId), + #[error("moving {0} into {1} would create a cycle")] + Cycle(NodeId, NodeId), + #[error("{0}")] + Invalid(String), +} + +pub type DocResult = Result; + +/// Raw image bytes (encoded PNG/JPEG/…) kept by content hash. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageData { + pub bytes: Arc>, + pub width: u32, + pub height: u32, +} + +/// A whole design file. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Document { + pub name: String, + /// Page ids in order. + pub pages: Vec, + nodes: BTreeMap>, + next_id: u64, + #[serde(default)] + pub styles: Vec