M0.1-M0.3: workspace, licences, geom/color/doc crates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -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 }
|
||||
@@ -1,3 +1,10 @@
|
||||
*~
|
||||
plan/
|
||||
plans/
|
||||
/target/
|
||||
/target-*/
|
||||
/dist/
|
||||
/corpus/
|
||||
/log/
|
||||
.DS_Store
|
||||
*.profraw
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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<Self> {
|
||||
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key || m.label().replace(' ', "").to_ascii_lowercase() == key)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let digits: Vec<u8> = 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<Self> {
|
||||
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<f32> {
|
||||
let p = parts.get(i)?;
|
||||
if let Some(pc) = p.strip_suffix('%') { pc.trim().parse::<f32>().ok().map(|v| v / 100.0) } else { p.parse::<f32>().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::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().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<String> {
|
||||
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn parse_alpha(a: &str) -> Option<f32> {
|
||||
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().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<ColorStop>,
|
||||
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<ColorStop> {
|
||||
let mut s: Vec<ColorStop> =
|
||||
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<String>, 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<Color> {
|
||||
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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<BezPath> {
|
||||
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<BezPath> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = doc.ancestors(leaf);
|
||||
chain.reverse();
|
||||
chain.push(leaf);
|
||||
// chain[0] is the page.
|
||||
let chain: Vec<NodeId> = 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<usize> = 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<NodeId>) -> Vec<NodeId> {
|
||||
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<NodeId>) -> Vec<NodeId> {
|
||||
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<Point> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
// Accept "12" and Figma-style "0:12".
|
||||
let t = s.trim();
|
||||
let t = t.rsplit(':').next().unwrap_or(t);
|
||||
t.parse::<u64>().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<T> = Result<T, DocError>;
|
||||
|
||||
/// Raw image bytes (encoded PNG/JPEG/…) kept by content hash.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImageData {
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
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<NodeId>,
|
||||
nodes: BTreeMap<NodeId, Arc<Node>>,
|
||||
next_id: u64,
|
||||
#[serde(default)]
|
||||
pub styles: Vec<Style>,
|
||||
#[serde(default)]
|
||||
pub variables: Variables,
|
||||
/// Images by content hash; serialized separately by the format crate.
|
||||
#[serde(skip)]
|
||||
pub images: BTreeMap<String, ImageData>,
|
||||
/// The page shown when the file opens.
|
||||
#[serde(default)]
|
||||
pub current_page: Option<NodeId>,
|
||||
/// Colours used recently / saved in "Document colors".
|
||||
#[serde(default)]
|
||||
pub document_colors: Vec<Color>,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
fn default() -> Self {
|
||||
Self::new("Untitled")
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A new file with one empty page ("Page 1").
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let mut doc = Self {
|
||||
name: name.into(),
|
||||
pages: Vec::new(),
|
||||
nodes: BTreeMap::new(),
|
||||
next_id: 1,
|
||||
styles: Vec::new(),
|
||||
variables: Variables::default(),
|
||||
images: BTreeMap::new(),
|
||||
current_page: None,
|
||||
document_colors: Vec::new(),
|
||||
};
|
||||
let p = doc.add_page("Page 1");
|
||||
doc.current_page = Some(p);
|
||||
doc
|
||||
}
|
||||
|
||||
/// A fresh unused id.
|
||||
pub fn alloc_id(&mut self) -> NodeId {
|
||||
let id = NodeId(self.next_id);
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn node(&self, id: NodeId) -> Option<&Node> {
|
||||
self.nodes.get(&id).map(Arc::as_ref)
|
||||
}
|
||||
|
||||
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
|
||||
self.nodes.get(&id)
|
||||
}
|
||||
|
||||
/// Mutable access (copy-on-write when a snapshot shares the node).
|
||||
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
|
||||
self.nodes.get_mut(&id).map(Arc::make_mut)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: NodeId) -> DocResult<&Node> {
|
||||
self.node(id).ok_or(DocError::NoNode(id))
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, id: NodeId) -> DocResult<&mut Node> {
|
||||
self.node_mut(id).ok_or(DocError::NoNode(id))
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: NodeId) -> bool {
|
||||
self.nodes.contains_key(&id)
|
||||
}
|
||||
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Every node id (pages included), ascending.
|
||||
pub fn ids(&self) -> impl Iterator<Item = NodeId> + '_ {
|
||||
self.nodes.keys().copied()
|
||||
}
|
||||
|
||||
pub fn nodes(&self) -> impl Iterator<Item = &Node> + '_ {
|
||||
self.nodes.values().map(Arc::as_ref)
|
||||
}
|
||||
|
||||
/// Add a page at the end.
|
||||
pub fn add_page(&mut self, name: &str) -> NodeId {
|
||||
let id = self.alloc_id();
|
||||
let node = Node::new(
|
||||
id,
|
||||
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
||||
name,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.pages.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
/// The page a node is on (a page is on itself).
|
||||
pub fn page_of(&self, id: NodeId) -> Option<NodeId> {
|
||||
let mut cur = id;
|
||||
for _ in 0..10_000 {
|
||||
let n = self.node(cur)?;
|
||||
if n.is_page() {
|
||||
return Some(cur);
|
||||
}
|
||||
cur = n.parent?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn first_page(&self) -> Option<NodeId> {
|
||||
self.pages.first().copied()
|
||||
}
|
||||
|
||||
/// Insert `node` (its id must come from [`alloc_id`](Self::alloc_id)) as a child of `parent` at
|
||||
/// `index` (None = on top).
|
||||
pub fn insert(&mut self, mut node: Node, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
||||
let p = self.get(parent)?;
|
||||
if !p.kind.is_container() {
|
||||
return Err(DocError::NotContainer(parent));
|
||||
}
|
||||
let id = node.id;
|
||||
if self.nodes.contains_key(&id) {
|
||||
return Err(DocError::Invalid(format!("node {id} already exists")));
|
||||
}
|
||||
if id.0 >= self.next_id {
|
||||
self.next_id = id.0.saturating_add(1);
|
||||
}
|
||||
node.parent = Some(parent);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
let p = self.get_mut(parent)?;
|
||||
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
||||
p.children.insert(at, id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Create a node of `kind` under `parent`, positioned at `(x, y)` in the parent's space.
|
||||
pub fn create(&mut self, parent: NodeId, kind: NodeKind, name: &str, x: f64, y: f64, w: f64, h: f64) -> DocResult<NodeId> {
|
||||
let id = self.alloc_id();
|
||||
let mut node = Node::new(id, kind, name, w, h);
|
||||
node.transform = Affine::translate((x, y));
|
||||
self.insert(node, parent, None)
|
||||
}
|
||||
|
||||
/// Whether `ancestor` is `id` or one of its ancestors.
|
||||
pub fn is_ancestor(&self, ancestor: NodeId, id: NodeId) -> bool {
|
||||
let mut cur = Some(id);
|
||||
let mut guard = 0;
|
||||
while let Some(c) = cur {
|
||||
if c == ancestor {
|
||||
return true;
|
||||
}
|
||||
guard += 1;
|
||||
if guard > 10_000 {
|
||||
return false;
|
||||
}
|
||||
cur = self.node(c).and_then(|n| n.parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Ancestors from the parent up to (and including) the page.
|
||||
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = self.node(id).and_then(|n| n.parent);
|
||||
while let Some(c) = cur {
|
||||
if out.len() > 10_000 {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
cur = self.node(c).and_then(|n| n.parent);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `id` and all its descendants, pre-order.
|
||||
pub fn subtree(&self, id: NodeId) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![id];
|
||||
while let Some(c) = stack.pop() {
|
||||
if out.len() > 5_000_000 {
|
||||
break;
|
||||
}
|
||||
let Some(n) = self.node(c) else { continue };
|
||||
out.push(c);
|
||||
stack.extend(n.children.iter().rev());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove a node and its subtree. Pages can only be removed while another page remains.
|
||||
pub fn remove(&mut self, id: NodeId) -> DocResult<()> {
|
||||
let node = self.get(id)?;
|
||||
let parent = node.parent;
|
||||
if node.is_page() {
|
||||
if self.pages.len() <= 1 {
|
||||
return Err(DocError::Invalid("a file needs at least one page".into()));
|
||||
}
|
||||
self.pages.retain(|p| *p != id);
|
||||
if self.current_page == Some(id) {
|
||||
self.current_page = self.pages.first().copied();
|
||||
}
|
||||
}
|
||||
if let Some(parent) = parent
|
||||
&& let Some(p) = self.node_mut(parent)
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
for d in self.subtree(id) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index of `id` among its siblings.
|
||||
pub fn index_in_parent(&self, id: NodeId) -> Option<usize> {
|
||||
let parent = self.node(id)?.parent?;
|
||||
self.node(parent)?.children.iter().position(|c| *c == id)
|
||||
}
|
||||
|
||||
/// Move `id` under `new_parent` at `index` (None = top), keeping its world position.
|
||||
pub fn reparent(&mut self, id: NodeId, new_parent: NodeId, index: Option<usize>) -> DocResult<()> {
|
||||
if self.is_ancestor(id, new_parent) {
|
||||
return Err(DocError::Cycle(id, new_parent));
|
||||
}
|
||||
let np = self.get(new_parent)?;
|
||||
if !np.kind.is_container() {
|
||||
return Err(DocError::NotContainer(new_parent));
|
||||
}
|
||||
let node = self.get(id)?;
|
||||
if node.is_page() {
|
||||
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
|
||||
}
|
||||
let world = self.world_transform(id);
|
||||
let old_parent = node.parent;
|
||||
let old_index = self.index_in_parent(id);
|
||||
if let Some(op) = old_parent
|
||||
&& let Some(p) = self.node_mut(op)
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
let parent_world = self.world_transform(new_parent);
|
||||
let np = self.get_mut(new_parent)?;
|
||||
let mut at = index.unwrap_or(np.children.len());
|
||||
// Moving within the same parent: the removal shifted later indices.
|
||||
if old_parent == Some(new_parent)
|
||||
&& let (Some(oi), Some(_)) = (old_index, index)
|
||||
&& oi < at
|
||||
{
|
||||
at = at.saturating_sub(1);
|
||||
}
|
||||
let at = at.min(np.children.len());
|
||||
np.children.insert(at, id);
|
||||
let n = self.get_mut(id)?;
|
||||
n.parent = Some(new_parent);
|
||||
n.transform = parent_world.inverse() * world;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reorder `id` within its parent to `index`.
|
||||
pub fn reorder(&mut self, id: NodeId, index: usize) -> DocResult<()> {
|
||||
let parent = self.get(id)?.parent.ok_or(DocError::Invalid("page".into()))?;
|
||||
let p = self.get_mut(parent)?;
|
||||
p.children.retain(|c| *c != id);
|
||||
let at = index.min(p.children.len());
|
||||
p.children.insert(at, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transform from `id`'s local space to its page.
|
||||
pub fn world_transform(&self, id: NodeId) -> Affine {
|
||||
let mut xf = Affine::IDENTITY;
|
||||
let mut cur = Some(id);
|
||||
let mut guard = 0;
|
||||
while let Some(c) = cur {
|
||||
let Some(n) = self.node(c) else { break };
|
||||
if n.is_page() {
|
||||
break;
|
||||
}
|
||||
xf = n.transform * xf;
|
||||
cur = n.parent;
|
||||
guard += 1;
|
||||
if guard > 10_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
xf
|
||||
}
|
||||
|
||||
/// Set `id`'s transform so that its world transform becomes `world`.
|
||||
pub fn set_world_transform(&mut self, id: NodeId, world: Affine) -> DocResult<()> {
|
||||
let parent = self.get(id)?.parent;
|
||||
let pw = parent.map(|p| self.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
self.get_mut(id)?.transform = pw.inverse() * world;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of the node's box in page coordinates.
|
||||
pub fn world_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
if n.is_page() {
|
||||
return self.page_content_bounds(id);
|
||||
}
|
||||
Some(figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect()))
|
||||
}
|
||||
|
||||
/// Bounds of everything painted by a node, effects and strokes included (page coordinates).
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let reach = n.paint_reach();
|
||||
let mut r = figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect().inflate(reach, reach));
|
||||
if !(n.clip && n.kind.is_frame_like()) || n.kind.bounds_follow_children() {
|
||||
for c in &n.children {
|
||||
if let Some(cr) = self.world_render_bounds(*c) {
|
||||
r = r.union(cr);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Union of the top-level nodes' bounds on a page.
|
||||
pub fn page_content_bounds(&self, page: NodeId) -> Option<Rect> {
|
||||
let p = self.node(page)?;
|
||||
p.children.iter().filter_map(|c| self.world_bounds(*c)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// Union of several nodes' world bounds.
|
||||
pub fn bounds_of(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||
ids.iter().filter_map(|i| self.world_bounds(*i)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// Resize a group/boolean so its box tightly wraps its children (children keep world position).
|
||||
pub fn fit_to_children(&mut self, id: NodeId) -> DocResult<()> {
|
||||
let n = self.get(id)?;
|
||||
if n.children.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let world = self.world_transform(id);
|
||||
let inv = world.inverse();
|
||||
// Children bounds in the group's current local space.
|
||||
let mut local: Option<Rect> = None;
|
||||
for c in &n.children {
|
||||
let Some(cn) = self.node(*c) else { continue };
|
||||
let r = figcraft_geom::transform_rect_bbox(inv * self.world_transform(*c), cn.local_rect());
|
||||
local = Some(local.map_or(r, |l| l.union(r)));
|
||||
}
|
||||
let Some(local) = local else { return Ok(()) };
|
||||
let children = n.children.clone();
|
||||
let child_worlds: Vec<(NodeId, Affine)> = children.iter().map(|c| (*c, self.world_transform(*c))).collect();
|
||||
let shift = Affine::translate((local.x0, local.y0));
|
||||
let nw = world * shift;
|
||||
{
|
||||
let g = self.get_mut(id)?;
|
||||
g.transform = g.transform * shift;
|
||||
g.width = local.width();
|
||||
g.height = local.height();
|
||||
}
|
||||
let ninv = nw.inverse();
|
||||
for (c, w) in child_worlds {
|
||||
if let Some(cn) = self.node_mut(c) {
|
||||
cn.transform = ninv * w;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fit every group/boolean ancestor of `id` (innermost first).
|
||||
pub fn fit_ancestors(&mut self, id: NodeId) {
|
||||
for a in self.ancestors(id) {
|
||||
let follows = self.node(a).is_some_and(|n| n.kind.bounds_follow_children());
|
||||
if follows {
|
||||
let _ = self.fit_to_children(a);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-copy `id`'s subtree under `parent` (at `index`), returning the new root id. The copy
|
||||
/// keeps the original's local transform; ids are fresh.
|
||||
pub fn duplicate_into(&mut self, id: NodeId, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
||||
let mut map = BTreeMap::new();
|
||||
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
||||
for n in &src_nodes {
|
||||
let nid = self.alloc_id();
|
||||
map.insert(n.id, nid);
|
||||
}
|
||||
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
||||
}
|
||||
|
||||
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
||||
/// remapped by `map`. Interactions and instance references pointing inside the subtree are
|
||||
/// remapped too.
|
||||
pub fn paste_nodes(&mut self, nodes: &[Node], map: &BTreeMap<NodeId, NodeId>, root: NodeId, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
||||
let p = self.get(parent)?;
|
||||
if !p.kind.is_container() {
|
||||
return Err(DocError::NotContainer(parent));
|
||||
}
|
||||
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
|
||||
for n in nodes {
|
||||
let Some(new_id) = map.get(&n.id) else { continue };
|
||||
let mut c = n.clone();
|
||||
c.id = *new_id;
|
||||
c.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
|
||||
c.parent = if n.id == root { Some(parent) } else { n.parent.and_then(|p| map.get(&p).copied()) };
|
||||
for it in &mut c.interactions {
|
||||
for a in &mut it.actions {
|
||||
remap_action(a, map);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &mut c.kind
|
||||
&& let Some(m) = map.get(component)
|
||||
{
|
||||
*component = *m;
|
||||
}
|
||||
if new_id.0 >= self.next_id {
|
||||
self.next_id = new_id.0.saturating_add(1);
|
||||
}
|
||||
self.nodes.insert(*new_id, Arc::new(c));
|
||||
}
|
||||
let p = self.get_mut(parent)?;
|
||||
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
||||
p.children.insert(at, new_root);
|
||||
Ok(new_root)
|
||||
}
|
||||
|
||||
/// Find nodes on `page` (or all pages) whose name contains `query` (case-insensitive).
|
||||
pub fn find_by_name(&self, query: &str, page: Option<NodeId>) -> Vec<NodeId> {
|
||||
let q = query.to_lowercase();
|
||||
let roots: Vec<NodeId> = page.map(|p| vec![p]).unwrap_or_else(|| self.pages.clone());
|
||||
roots
|
||||
.into_iter()
|
||||
.flat_map(|r| self.subtree(r))
|
||||
.filter(|i| self.node(*i).is_some_and(|n| !n.is_page() && n.name.to_lowercase().contains(&q)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The nearest frame-like ancestor (excluding `id` itself).
|
||||
pub fn containing_frame(&self, id: NodeId) -> Option<NodeId> {
|
||||
self.ancestors(id).into_iter().find(|a| self.node(*a).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })))
|
||||
}
|
||||
|
||||
/// The top-level frame containing `id` (or `id` if it is one).
|
||||
pub fn top_level_frame(&self, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(self.ancestors(id));
|
||||
// chain ends with the page; the element before it is the top-level node.
|
||||
let page_pos = chain.iter().position(|c| self.node(*c).is_some_and(Node::is_page))?;
|
||||
let top = *chain.get(page_pos.checked_sub(1)?)?;
|
||||
self.node(top).filter(|n| n.kind.is_frame_like()).map(|_| top)
|
||||
}
|
||||
|
||||
/// Top-level frames of a page in reading order (rows top to bottom, then left to right).
|
||||
pub fn top_frames(&self, page: NodeId) -> Vec<NodeId> {
|
||||
let Some(p) = self.node(page) else { return vec![] };
|
||||
let mut v: Vec<(NodeId, Rect)> = p
|
||||
.children
|
||||
.iter()
|
||||
.filter(|c| self.node(**c).is_some_and(|n| n.kind.is_frame_like()))
|
||||
.filter_map(|c| self.world_bounds(*c).map(|b| (*c, b)))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0).then(a.1.x0.total_cmp(&b.1.x0)));
|
||||
v.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// A unique name like "Frame 3" for a new node of a kind.
|
||||
pub fn next_name(&self, base: &str) -> String {
|
||||
let mut max = 0u64;
|
||||
for n in self.nodes() {
|
||||
if let Some(rest) = n.name.strip_prefix(base)
|
||||
&& let Some(num) = rest.strip_prefix(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
{
|
||||
max = max.max(k);
|
||||
}
|
||||
}
|
||||
format!("{base} {}", max + 1)
|
||||
}
|
||||
|
||||
pub fn style(&self, id: &str) -> Option<&Style> {
|
||||
self.styles.iter().find(|s| s.id == id || s.name == id)
|
||||
}
|
||||
|
||||
/// The effective variable modes for a node (walking up its ancestors).
|
||||
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut chain = vec![id];
|
||||
chain.extend(self.ancestors(id));
|
||||
for c in chain.into_iter().rev() {
|
||||
if let Some(n) = self.node(c) {
|
||||
for (k, v) in &n.var_modes {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check structural invariants (used by tests and after loading untrusted files).
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
for (id, n) in &self.nodes {
|
||||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
for c in &n.children {
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
None => issues.push(format!("child {c} of {id} missing")),
|
||||
}
|
||||
}
|
||||
if let Some(p) = n.parent
|
||||
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
|
||||
{
|
||||
issues.push(format!("{id} not listed in parent {p}"));
|
||||
}
|
||||
if n.is_page() != self.pages.contains(id) {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||
pub fn repair(&mut self) {
|
||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||
for id in &ids {
|
||||
if let Some(n) = self.nodes.get_mut(id) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.id = *id;
|
||||
}
|
||||
}
|
||||
// Drop dangling children and children claimed by another parent.
|
||||
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||
for id in &ids {
|
||||
let Some(n) = self.nodes.get(id) else { continue };
|
||||
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
||||
if keep != n.children
|
||||
&& let Some(n) = self.nodes.get_mut(id)
|
||||
{
|
||||
Arc::make_mut(n).children = keep;
|
||||
}
|
||||
}
|
||||
// Orphans (parent missing or not listing them) are removed.
|
||||
loop {
|
||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
||||
if n.is_page() {
|
||||
return None;
|
||||
}
|
||||
match n.parent {
|
||||
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
||||
_ => Some(*k),
|
||||
}
|
||||
});
|
||||
let Some(o) = orphan else { break };
|
||||
for d in self.subtree(o) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
}
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()));
|
||||
for (k, n) in &self.nodes {
|
||||
if n.is_page() && !self.pages.contains(k) {
|
||||
self.pages.push(*k);
|
||||
}
|
||||
}
|
||||
if self.pages.is_empty() {
|
||||
let p = self.add_page("Page 1");
|
||||
self.current_page = Some(p);
|
||||
}
|
||||
if let Some(max) = self.nodes.keys().max()
|
||||
&& self.next_id <= max.0
|
||||
{
|
||||
self.next_id = max.0.saturating_add(1);
|
||||
}
|
||||
if self.current_page.is_none_or(|p| !self.pages.contains(&p)) {
|
||||
self.current_page = self.pages.first().copied();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click.
|
||||
/// `deep` selects the innermost leaf (Cmd-click); otherwise top-level children of frames
|
||||
/// resolve like Figma's click-through rules (see [`geometry::select_target`]).
|
||||
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64) -> Option<NodeId> {
|
||||
geometry::hit_leaf(self, page, pt, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
||||
use proto::Action::*;
|
||||
match a {
|
||||
Navigate { dest } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {
|
||||
if let Some(m) = map.get(dest) {
|
||||
*dest = *m;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
|
||||
|
||||
use crate::NodeId;
|
||||
use crate::props::*;
|
||||
use crate::proto::{Interaction, Overflow, ScrollPosition};
|
||||
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
|
||||
use crate::text::TextData;
|
||||
use crate::vector::VectorNetwork;
|
||||
use figcraft_color::{BlendMode, Color, Paint};
|
||||
use figcraft_geom::Affine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Boolean operation of a boolean group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BoolOp {
|
||||
#[default]
|
||||
Union,
|
||||
Subtract,
|
||||
Intersect,
|
||||
Exclude,
|
||||
}
|
||||
|
||||
impl BoolOp {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
BoolOp::Union => "Union",
|
||||
BoolOp::Subtract => "Subtract",
|
||||
BoolOp::Intersect => "Intersect",
|
||||
BoolOp::Exclude => "Exclude",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A component property definition.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PropDef {
|
||||
Boolean { default: bool },
|
||||
Text { default: String },
|
||||
InstanceSwap { default: NodeId },
|
||||
Variant { options: Vec<String>, default: String },
|
||||
}
|
||||
|
||||
/// What a node is, with per-type data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum NodeKind {
|
||||
Page {
|
||||
#[serde(default = "page_bg")]
|
||||
background: Color,
|
||||
#[serde(default)]
|
||||
flows: Vec<crate::proto::Flow>,
|
||||
#[serde(default)]
|
||||
prototype: crate::proto::PrototypeSettings,
|
||||
#[serde(default)]
|
||||
guides: Vec<Guide>,
|
||||
},
|
||||
Frame,
|
||||
Group,
|
||||
Section {
|
||||
#[serde(default)]
|
||||
contents_hidden: bool,
|
||||
},
|
||||
Rectangle,
|
||||
Ellipse {
|
||||
#[serde(default)]
|
||||
start: f64,
|
||||
#[serde(default = "tau")]
|
||||
sweep: f64,
|
||||
#[serde(default)]
|
||||
inner: f64,
|
||||
},
|
||||
Polygon {
|
||||
count: u32,
|
||||
},
|
||||
Star {
|
||||
count: u32,
|
||||
inner_ratio: f64,
|
||||
},
|
||||
Line,
|
||||
Vector {
|
||||
network: VectorNetwork,
|
||||
},
|
||||
Text(TextData),
|
||||
BooleanOp {
|
||||
op: BoolOp,
|
||||
},
|
||||
Component {
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
props: BTreeMap<String, PropDef>,
|
||||
},
|
||||
ComponentSet {
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
},
|
||||
Instance {
|
||||
component: NodeId,
|
||||
/// Values for the component's properties.
|
||||
#[serde(default)]
|
||||
props: BTreeMap<String, serde_json::Value>,
|
||||
},
|
||||
Slice,
|
||||
}
|
||||
|
||||
fn page_bg() -> Color {
|
||||
Color::DEFAULT_PAGE
|
||||
}
|
||||
fn tau() -> f64 {
|
||||
std::f64::consts::TAU
|
||||
}
|
||||
|
||||
/// A ruler guide on a page or frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
/// `true` for a vertical guide (x position), `false` for horizontal (y).
|
||||
pub vertical: bool,
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
impl NodeKind {
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
NodeKind::Page { .. } => "PAGE",
|
||||
NodeKind::Frame => "FRAME",
|
||||
NodeKind::Group => "GROUP",
|
||||
NodeKind::Section { .. } => "SECTION",
|
||||
NodeKind::Rectangle => "RECTANGLE",
|
||||
NodeKind::Ellipse { .. } => "ELLIPSE",
|
||||
NodeKind::Polygon { .. } => "POLYGON",
|
||||
NodeKind::Star { .. } => "STAR",
|
||||
NodeKind::Line => "LINE",
|
||||
NodeKind::Vector { .. } => "VECTOR",
|
||||
NodeKind::Text(_) => "TEXT",
|
||||
NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION",
|
||||
NodeKind::Component { .. } => "COMPONENT",
|
||||
NodeKind::ComponentSet { .. } => "COMPONENT_SET",
|
||||
NodeKind::Instance { .. } => "INSTANCE",
|
||||
NodeKind::Slice => "SLICE",
|
||||
}
|
||||
}
|
||||
|
||||
/// The label the design panel's type dropdown shows.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
NodeKind::Page { .. } => "Page",
|
||||
NodeKind::Frame => "Frame",
|
||||
NodeKind::Group => "Group",
|
||||
NodeKind::Section { .. } => "Section",
|
||||
NodeKind::Rectangle => "Rectangle",
|
||||
NodeKind::Ellipse { .. } => "Ellipse",
|
||||
NodeKind::Polygon { .. } => "Polygon",
|
||||
NodeKind::Star { .. } => "Star",
|
||||
NodeKind::Line => "Line",
|
||||
NodeKind::Vector { .. } => "Vector",
|
||||
NodeKind::Text(_) => "Text",
|
||||
NodeKind::BooleanOp { op } => op.label(),
|
||||
NodeKind::Component { .. } => "Component",
|
||||
NodeKind::ComponentSet { .. } => "Component set",
|
||||
NodeKind::Instance { .. } => "Instance",
|
||||
NodeKind::Slice => "Slice",
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame-like nodes: can have auto layout, clip content, layout grids, and act as artboards.
|
||||
pub fn is_frame_like(&self) -> bool {
|
||||
matches!(self, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. })
|
||||
}
|
||||
|
||||
/// Nodes that may contain children.
|
||||
pub fn is_container(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
NodeKind::Page { .. }
|
||||
| NodeKind::Frame
|
||||
| NodeKind::Group
|
||||
| NodeKind::Section { .. }
|
||||
| NodeKind::BooleanOp { .. }
|
||||
| NodeKind::Component { .. }
|
||||
| NodeKind::ComponentSet { .. }
|
||||
| NodeKind::Instance { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Group-like containers whose bounds follow their children.
|
||||
pub fn bounds_follow_children(&self) -> bool {
|
||||
matches!(self, NodeKind::Group | NodeKind::BooleanOp { .. })
|
||||
}
|
||||
|
||||
/// Shapes with a geometric outline of their own.
|
||||
pub fn is_shape(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Line | NodeKind::Vector { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Kinds whose corners can be rounded from the panel.
|
||||
pub fn has_corner_radius(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
NodeKind::Rectangle
|
||||
| NodeKind::Frame
|
||||
| NodeKind::Component { .. }
|
||||
| NodeKind::ComponentSet { .. }
|
||||
| NodeKind::Instance { .. }
|
||||
| NodeKind::Polygon { .. }
|
||||
| NodeKind::Star { .. }
|
||||
| NodeKind::Vector { .. }
|
||||
| NodeKind::Section { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Option<&TextData> {
|
||||
match self {
|
||||
NodeKind::Text(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_mut(&mut self) -> Option<&mut TextData> {
|
||||
match self {
|
||||
NodeKind::Text(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One node of the scene graph.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
pub id: NodeId,
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub kind: NodeKind,
|
||||
#[serde(default)]
|
||||
pub parent: Option<NodeId>,
|
||||
#[serde(default)]
|
||||
pub children: Vec<NodeId>,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
#[serde(default = "one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default = "pass")]
|
||||
pub blend: BlendMode,
|
||||
/// Transform relative to the parent (the page for top-level nodes).
|
||||
#[serde(default = "identity")]
|
||||
pub transform: Affine,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub fills: Vec<Paint>,
|
||||
#[serde(default)]
|
||||
pub strokes: Vec<Paint>,
|
||||
#[serde(default)]
|
||||
pub stroke: StrokeProps,
|
||||
#[serde(default)]
|
||||
pub effects: Vec<Effect>,
|
||||
#[serde(default)]
|
||||
pub corners: Corners,
|
||||
#[serde(default)]
|
||||
pub constraints: Constraints,
|
||||
/// Auto layout (frame-like nodes only).
|
||||
#[serde(default)]
|
||||
pub layout: Option<AutoLayout>,
|
||||
#[serde(default)]
|
||||
pub child_layout: ChildLayout,
|
||||
/// Clip content (frames).
|
||||
#[serde(default)]
|
||||
pub clip: bool,
|
||||
/// This node masks the siblings above it.
|
||||
#[serde(default)]
|
||||
pub is_mask: bool,
|
||||
#[serde(default)]
|
||||
pub grids: Vec<LayoutGrid>,
|
||||
#[serde(default)]
|
||||
pub exports: Vec<ExportSetting>,
|
||||
#[serde(default)]
|
||||
pub interactions: Vec<Interaction>,
|
||||
#[serde(default)]
|
||||
pub overflow: Overflow,
|
||||
#[serde(default)]
|
||||
pub scroll_position: ScrollPosition,
|
||||
#[serde(default)]
|
||||
pub styles: StyleRefs,
|
||||
/// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id.
|
||||
#[serde(default)]
|
||||
pub bound: BTreeMap<String, VariableId>,
|
||||
/// Explicit variable modes (collection → mode) set on this container.
|
||||
#[serde(default)]
|
||||
pub var_modes: BTreeMap<CollectionId, ModeId>,
|
||||
/// Lock aspect ratio when resizing from the panel.
|
||||
#[serde(default)]
|
||||
pub constrain_proportions: bool,
|
||||
/// Inside an instance: the node of the main component this one mirrors.
|
||||
#[serde(default)]
|
||||
pub source: Option<NodeId>,
|
||||
/// Inside an instance: property groups overridden locally ("fills", "characters", …).
|
||||
#[serde(default)]
|
||||
pub overrides: BTreeSet<String>,
|
||||
/// Component property references: which property controls this node's
|
||||
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap).
|
||||
#[serde(default)]
|
||||
pub prop_refs: BTreeMap<String, String>,
|
||||
/// Free-form plugin / agent data.
|
||||
#[serde(default)]
|
||||
pub plugin_data: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn pass() -> BlendMode {
|
||||
BlendMode::PassThrough
|
||||
}
|
||||
fn identity() -> Affine {
|
||||
Affine::IDENTITY
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// A node with Figma's defaults for its kind.
|
||||
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
|
||||
let fills = match &kind {
|
||||
NodeKind::Frame | NodeKind::Component { .. } => vec![Paint::solid(Color::WHITE)],
|
||||
NodeKind::Section { .. } => vec![Paint::solid(Color::WHITE)],
|
||||
NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } => {
|
||||
vec![Paint::solid(Color::DEFAULT_SHAPE)]
|
||||
}
|
||||
NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)],
|
||||
_ => vec![],
|
||||
};
|
||||
let strokes = match &kind {
|
||||
NodeKind::Line => vec![Paint::solid(Color::BLACK)],
|
||||
NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))],
|
||||
_ => vec![],
|
||||
};
|
||||
let stroke = match &kind {
|
||||
NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() },
|
||||
NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() },
|
||||
_ => StrokeProps::default(),
|
||||
};
|
||||
let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal };
|
||||
let clip = matches!(kind, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
|
||||
let corners = match &kind {
|
||||
NodeKind::ComponentSet { .. } => Corners { radii: [5.0; 4], smoothing: 0.0 },
|
||||
_ => Corners::default(),
|
||||
};
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
kind,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1.0,
|
||||
blend,
|
||||
transform: Affine::IDENTITY,
|
||||
width: width.max(0.0),
|
||||
height: height.max(0.0),
|
||||
fills,
|
||||
strokes,
|
||||
stroke,
|
||||
effects: Vec::new(),
|
||||
corners,
|
||||
constraints: Constraints::default(),
|
||||
layout: None,
|
||||
child_layout: ChildLayout::default(),
|
||||
clip,
|
||||
is_mask: false,
|
||||
grids: Vec::new(),
|
||||
exports: Vec::new(),
|
||||
interactions: Vec::new(),
|
||||
overflow: Default::default(),
|
||||
scroll_position: Default::default(),
|
||||
styles: StyleRefs::default(),
|
||||
bound: BTreeMap::new(),
|
||||
var_modes: BTreeMap::new(),
|
||||
constrain_proportions: false,
|
||||
source: None,
|
||||
overrides: BTreeSet::new(),
|
||||
prop_refs: BTreeMap::new(),
|
||||
plugin_data: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (f64, f64) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn local_rect(&self) -> figcraft_geom::Rect {
|
||||
figcraft_geom::Rect::new(0.0, 0.0, self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn is_page(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Page { .. })
|
||||
}
|
||||
|
||||
/// Whether painted strokes are present.
|
||||
pub fn has_visible_stroke(&self) -> bool {
|
||||
self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0
|
||||
}
|
||||
|
||||
/// Maximum distance (in local units) painting can reach outside the node box.
|
||||
pub fn paint_reach(&self) -> f64 {
|
||||
let stroke = if self.has_visible_stroke() {
|
||||
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or(self.stroke.weight);
|
||||
let base = match self.stroke.align {
|
||||
StrokeAlign::Inside => 0.0,
|
||||
StrokeAlign::Center => w / 2.0,
|
||||
StrokeAlign::Outside => w,
|
||||
};
|
||||
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w * 3.0 } else { w };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * (self.stroke.miter_limit.max(1.0)).min(4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
stroke + fx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
//! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports.
|
||||
|
||||
use figcraft_color::{BlendMode, Color};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where a stroke sits relative to the shape edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StrokeAlign {
|
||||
#[default]
|
||||
Inside,
|
||||
Center,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StrokeCap {
|
||||
#[default]
|
||||
None,
|
||||
Round,
|
||||
Square,
|
||||
LineArrow,
|
||||
TriangleArrow,
|
||||
ReversedTriangle,
|
||||
CircleFilled,
|
||||
DiamondFilled,
|
||||
}
|
||||
|
||||
impl StrokeCap {
|
||||
pub const ALL: [StrokeCap; 8] = [
|
||||
StrokeCap::None,
|
||||
StrokeCap::Round,
|
||||
StrokeCap::Square,
|
||||
StrokeCap::LineArrow,
|
||||
StrokeCap::TriangleArrow,
|
||||
StrokeCap::ReversedTriangle,
|
||||
StrokeCap::CircleFilled,
|
||||
StrokeCap::DiamondFilled,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StrokeCap::None => "None",
|
||||
StrokeCap::Round => "Round",
|
||||
StrokeCap::Square => "Square",
|
||||
StrokeCap::LineArrow => "Line arrow",
|
||||
StrokeCap::TriangleArrow => "Triangle arrow",
|
||||
StrokeCap::ReversedTriangle => "Reversed triangle",
|
||||
StrokeCap::CircleFilled => "Circle",
|
||||
StrokeCap::DiamondFilled => "Diamond",
|
||||
}
|
||||
}
|
||||
pub fn is_arrow(self) -> bool {
|
||||
!matches!(self, StrokeCap::None | StrokeCap::Round | StrokeCap::Square)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StrokeJoin {
|
||||
#[default]
|
||||
Miter,
|
||||
Bevel,
|
||||
Round,
|
||||
}
|
||||
|
||||
/// Stroke geometry shared by every stroke paint of a node.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct StrokeProps {
|
||||
pub weight: f64,
|
||||
pub align: StrokeAlign,
|
||||
/// Per-side weights `[top, right, bottom, left]` (rectangles and frames only).
|
||||
pub sides: Option<[f64; 4]>,
|
||||
pub cap_start: StrokeCap,
|
||||
pub cap_end: StrokeCap,
|
||||
pub join: StrokeJoin,
|
||||
pub miter_limit: f64,
|
||||
/// Dash pattern (dash, gap, …); empty = solid.
|
||||
pub dashes: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for StrokeProps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
weight: 1.0,
|
||||
align: StrokeAlign::Inside,
|
||||
sides: None,
|
||||
cap_start: StrokeCap::None,
|
||||
cap_end: StrokeCap::None,
|
||||
join: StrokeJoin::Miter,
|
||||
miter_limit: 4.0,
|
||||
dashes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shadow (drop or inner).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Shadow {
|
||||
pub color: Color,
|
||||
pub offset: (f64, f64),
|
||||
/// Blur radius.
|
||||
pub radius: f64,
|
||||
pub spread: f64,
|
||||
pub blend: BlendMode,
|
||||
/// Drop shadows only: show behind transparent areas of the layer.
|
||||
pub show_behind: bool,
|
||||
}
|
||||
|
||||
impl Default for Shadow {
|
||||
fn default() -> Self {
|
||||
Self { color: Color::new(0.0, 0.0, 0.0, 0.25), offset: (0.0, 4.0), radius: 4.0, spread: 0.0, blend: BlendMode::Normal, show_behind: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// One effect in a node's effect list.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum EffectKind {
|
||||
DropShadow(Shadow),
|
||||
InnerShadow(Shadow),
|
||||
LayerBlur { radius: f64 },
|
||||
BackgroundBlur { radius: f64 },
|
||||
/// Monotone grain over the layer.
|
||||
Noise {
|
||||
#[serde(default = "noise_size")]
|
||||
size: f64,
|
||||
#[serde(default = "noise_density")]
|
||||
density: f64,
|
||||
#[serde(default = "noise_color")]
|
||||
color: Color,
|
||||
},
|
||||
/// Roughened edges.
|
||||
Texture {
|
||||
#[serde(default = "noise_size")]
|
||||
size: f64,
|
||||
#[serde(default = "texture_radius")]
|
||||
radius: f64,
|
||||
},
|
||||
}
|
||||
|
||||
fn noise_size() -> f64 {
|
||||
0.5
|
||||
}
|
||||
fn noise_density() -> f64 {
|
||||
0.5
|
||||
}
|
||||
fn texture_radius() -> f64 {
|
||||
4.0
|
||||
}
|
||||
fn noise_color() -> Color {
|
||||
Color::new(0.0, 0.0, 0.0, 0.25)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Effect {
|
||||
#[serde(flatten)]
|
||||
pub kind: EffectKind,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
pub fn drop_shadow() -> Self {
|
||||
Self { kind: EffectKind::DropShadow(Shadow::default()), visible: true }
|
||||
}
|
||||
pub fn inner_shadow() -> Self {
|
||||
Self { kind: EffectKind::InnerShadow(Shadow { offset: (0.0, 4.0), ..Shadow::default() }), visible: true }
|
||||
}
|
||||
pub fn layer_blur() -> Self {
|
||||
Self { kind: EffectKind::LayerBlur { radius: 4.0 }, visible: true }
|
||||
}
|
||||
pub fn background_blur() -> Self {
|
||||
Self { kind: EffectKind::BackgroundBlur { radius: 4.0 }, visible: true }
|
||||
}
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self.kind {
|
||||
EffectKind::DropShadow(_) => "Drop shadow",
|
||||
EffectKind::InnerShadow(_) => "Inner shadow",
|
||||
EffectKind::LayerBlur { .. } => "Layer blur",
|
||||
EffectKind::BackgroundBlur { .. } => "Background blur",
|
||||
EffectKind::Noise { .. } => "Noise",
|
||||
EffectKind::Texture { .. } => "Texture",
|
||||
}
|
||||
}
|
||||
/// How far outside the node's bounds this effect can paint.
|
||||
pub fn reach(&self) -> f64 {
|
||||
if !self.visible {
|
||||
return 0.0;
|
||||
}
|
||||
match &self.kind {
|
||||
EffectKind::DropShadow(s) => s.radius.abs() * 1.5 + s.spread.max(0.0) + s.offset.0.abs().max(s.offset.1.abs()),
|
||||
EffectKind::LayerBlur { radius } => radius.abs() * 1.5,
|
||||
EffectKind::Texture { radius, .. } => radius.abs(),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Corner radii `[tl, tr, br, bl]` and smoothing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Corners {
|
||||
pub radii: [f64; 4],
|
||||
pub smoothing: f64,
|
||||
}
|
||||
|
||||
impl Corners {
|
||||
pub fn uniform(&self) -> Option<f64> {
|
||||
let [a, b, c, d] = self.radii;
|
||||
if a == b && b == c && c == d { Some(a) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HConstraint {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
LeftRight,
|
||||
Center,
|
||||
Scale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VConstraint {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
TopBottom,
|
||||
Center,
|
||||
Scale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Constraints {
|
||||
pub horizontal: HConstraint,
|
||||
pub vertical: VConstraint,
|
||||
}
|
||||
|
||||
/// Auto layout direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LayoutMode {
|
||||
#[default]
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Alignment along the main axis.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MainAlign {
|
||||
#[default]
|
||||
Min,
|
||||
Center,
|
||||
Max,
|
||||
SpaceBetween,
|
||||
}
|
||||
|
||||
/// Alignment across the main axis.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CrossAlign {
|
||||
#[default]
|
||||
Min,
|
||||
Center,
|
||||
Max,
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// Auto layout container properties (a frame with auto layout).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AutoLayout {
|
||||
pub mode: LayoutMode,
|
||||
pub wrap: bool,
|
||||
/// Gap between items along the main axis.
|
||||
pub gap: f64,
|
||||
/// Gap between wrapped rows/columns.
|
||||
pub cross_gap: f64,
|
||||
/// Padding `[top, right, bottom, left]`.
|
||||
pub padding: [f64; 4],
|
||||
pub main_align: MainAlign,
|
||||
pub cross_align: CrossAlign,
|
||||
/// Grid mode: column and row counts.
|
||||
pub grid_columns: u32,
|
||||
pub grid_rows: u32,
|
||||
/// Include strokes in layout sizes.
|
||||
pub strokes_included: bool,
|
||||
/// Last item on top (canvas stacking).
|
||||
pub reverse_z: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: LayoutMode::Horizontal,
|
||||
wrap: false,
|
||||
gap: 10.0,
|
||||
cross_gap: 10.0,
|
||||
padding: [10.0; 4],
|
||||
main_align: MainAlign::Min,
|
||||
cross_align: CrossAlign::Min,
|
||||
grid_columns: 2,
|
||||
grid_rows: 2,
|
||||
strokes_included: false,
|
||||
reverse_z: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a node sizes on one axis.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Sizing {
|
||||
#[default]
|
||||
Fixed,
|
||||
/// Hug contents (auto layout frames, text).
|
||||
Hug,
|
||||
/// Fill the auto layout parent.
|
||||
Fill,
|
||||
}
|
||||
|
||||
impl Sizing {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Sizing::Fixed => "Fixed",
|
||||
Sizing::Hug => "Hug",
|
||||
Sizing::Fill => "Fill",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout properties a node has as a child (of an auto layout frame) or as a resizable box.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ChildLayout {
|
||||
pub sizing_h: Sizing,
|
||||
pub sizing_v: Sizing,
|
||||
/// Ignore auto layout (absolute position inside an auto layout frame).
|
||||
pub absolute: bool,
|
||||
pub min_w: Option<f64>,
|
||||
pub max_w: Option<f64>,
|
||||
pub min_h: Option<f64>,
|
||||
pub max_h: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GridKind {
|
||||
#[default]
|
||||
Grid,
|
||||
Columns,
|
||||
Rows,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GridAlign {
|
||||
#[default]
|
||||
Stretch,
|
||||
Min,
|
||||
Center,
|
||||
Max,
|
||||
}
|
||||
|
||||
/// A layout grid on a frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LayoutGrid {
|
||||
pub kind: GridKind,
|
||||
/// Grid: cell size. Columns/rows: count (0 = auto).
|
||||
pub size: f64,
|
||||
pub count: u32,
|
||||
pub gutter: f64,
|
||||
pub offset: f64,
|
||||
pub align: GridAlign,
|
||||
pub color: Color,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Default for LayoutGrid {
|
||||
fn default() -> Self {
|
||||
Self { kind: GridKind::Grid, size: 10.0, count: 5, gutter: 20.0, offset: 0.0, align: GridAlign::Stretch, color: Color::new(1.0, 0.0, 0.0, 0.1), visible: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutGrid {
|
||||
pub fn columns() -> Self {
|
||||
Self { kind: GridKind::Columns, count: 5, gutter: 20.0, size: 0.0, ..Self::default() }
|
||||
}
|
||||
pub fn rows() -> Self {
|
||||
Self { kind: GridKind::Rows, count: 5, gutter: 20.0, size: 0.0, color: Color::new(0.0, 0.4, 1.0, 0.1), ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Png,
|
||||
Jpg,
|
||||
Svg,
|
||||
Pdf,
|
||||
Webp,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn ext(self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::Png => "png",
|
||||
ExportFormat::Jpg => "jpg",
|
||||
ExportFormat::Svg => "svg",
|
||||
ExportFormat::Pdf => "pdf",
|
||||
ExportFormat::Webp => "webp",
|
||||
}
|
||||
}
|
||||
pub fn from_ext(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().trim_start_matches('.') {
|
||||
"png" => Some(ExportFormat::Png),
|
||||
"jpg" | "jpeg" => Some(ExportFormat::Jpg),
|
||||
"svg" => Some(ExportFormat::Svg),
|
||||
"pdf" => Some(ExportFormat::Pdf),
|
||||
"webp" => Some(ExportFormat::Webp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export size constraint.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "by", rename_all = "camelCase")]
|
||||
pub enum ExportSize {
|
||||
Scale { value: f64 },
|
||||
Width { value: f64 },
|
||||
Height { value: f64 },
|
||||
}
|
||||
|
||||
impl Default for ExportSize {
|
||||
fn default() -> Self {
|
||||
ExportSize::Scale { value: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a node's Export section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ExportSetting {
|
||||
pub size: ExportSize,
|
||||
pub suffix: String,
|
||||
pub format: ExportFormat,
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Prototype data: interactions (trigger → actions) with transitions, flows and overflow.
|
||||
|
||||
use crate::NodeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Trigger {
|
||||
OnClick,
|
||||
OnHover,
|
||||
OnPress,
|
||||
OnDrag,
|
||||
MouseEnter,
|
||||
MouseLeave,
|
||||
MouseDown,
|
||||
MouseUp,
|
||||
AfterDelay { ms: u32 },
|
||||
KeyPress { key: String },
|
||||
}
|
||||
|
||||
impl Default for Trigger {
|
||||
fn default() -> Self {
|
||||
Trigger::OnClick
|
||||
}
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Trigger::OnClick => "On click".into(),
|
||||
Trigger::OnHover => "While hovering".into(),
|
||||
Trigger::OnPress => "While pressing".into(),
|
||||
Trigger::OnDrag => "On drag".into(),
|
||||
Trigger::MouseEnter => "Mouse enter".into(),
|
||||
Trigger::MouseLeave => "Mouse leave".into(),
|
||||
Trigger::MouseDown => "Mouse down".into(),
|
||||
Trigger::MouseUp => "Mouse up".into(),
|
||||
Trigger::AfterDelay { ms } => format!("After delay {ms}ms"),
|
||||
Trigger::KeyPress { key } => format!("Key/Gamepad {key}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Direction {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Easing {
|
||||
Linear,
|
||||
EaseIn,
|
||||
#[default]
|
||||
EaseOut,
|
||||
EaseInOut,
|
||||
EaseInBack,
|
||||
EaseOutBack,
|
||||
EaseInOutBack,
|
||||
Gentle,
|
||||
Quick,
|
||||
Bouncy,
|
||||
Slow,
|
||||
}
|
||||
|
||||
impl Easing {
|
||||
/// Evaluate the curve at `t` (0..1).
|
||||
pub fn eval(self, t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let back = 1.70158;
|
||||
match self {
|
||||
Easing::Linear => t,
|
||||
Easing::EaseIn => t * t * t,
|
||||
Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
|
||||
Easing::EaseInOut => {
|
||||
if t < 0.5 {
|
||||
4.0 * t * t * t
|
||||
} else {
|
||||
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
||||
}
|
||||
}
|
||||
Easing::EaseInBack => (back + 1.0) * t * t * t - back * t * t,
|
||||
Easing::EaseOutBack => 1.0 + (back + 1.0) * (t - 1.0).powi(3) + back * (t - 1.0).powi(2),
|
||||
Easing::EaseInOutBack => {
|
||||
let c2 = back * 1.525;
|
||||
if t < 0.5 {
|
||||
((2.0 * t).powi(2) * ((c2 + 1.0) * 2.0 * t - c2)) / 2.0
|
||||
} else {
|
||||
((2.0 * t - 2.0).powi(2) * ((c2 + 1.0) * (t * 2.0 - 2.0) + c2) + 2.0) / 2.0
|
||||
}
|
||||
}
|
||||
// Spring presets approximated by damped oscillations.
|
||||
Easing::Gentle => spring(t, 6.0, 0.7),
|
||||
Easing::Quick => spring(t, 10.0, 0.8),
|
||||
Easing::Bouncy => spring(t, 9.0, 0.35),
|
||||
Easing::Slow => spring(t, 4.0, 0.9),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spring(t: f64, freq: f64, damping: f64) -> f64 {
|
||||
if t >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let decay = (-damping * 8.0 * t).exp();
|
||||
1.0 - decay * (freq * t * std::f64::consts::PI).cos() * (1.0 - t)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Transition {
|
||||
Instant,
|
||||
Dissolve,
|
||||
SmartAnimate,
|
||||
MoveIn { direction: Direction },
|
||||
MoveOut { direction: Direction },
|
||||
Push { direction: Direction },
|
||||
SlideIn { direction: Direction },
|
||||
SlideOut { direction: Direction },
|
||||
}
|
||||
|
||||
impl Default for Transition {
|
||||
fn default() -> Self {
|
||||
Transition::Instant
|
||||
}
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Transition::Instant => "Instant",
|
||||
Transition::Dissolve => "Dissolve",
|
||||
Transition::SmartAnimate => "Smart animate",
|
||||
Transition::MoveIn { .. } => "Move in",
|
||||
Transition::MoveOut { .. } => "Move out",
|
||||
Transition::Push { .. } => "Push",
|
||||
Transition::SlideIn { .. } => "Slide in",
|
||||
Transition::SlideOut { .. } => "Slide out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OverlayPosition {
|
||||
#[default]
|
||||
Center,
|
||||
TopLeft,
|
||||
TopCenter,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomCenter,
|
||||
BottomRight,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Action {
|
||||
Navigate { dest: NodeId },
|
||||
Back,
|
||||
ScrollTo { dest: NodeId },
|
||||
OpenOverlay {
|
||||
dest: NodeId,
|
||||
#[serde(default)]
|
||||
position: OverlayPosition,
|
||||
#[serde(default)]
|
||||
close_on_outside: bool,
|
||||
},
|
||||
SwapOverlay { dest: NodeId },
|
||||
CloseOverlay,
|
||||
OpenUrl { url: String },
|
||||
/// Change an instance to another variant (interactive components).
|
||||
ChangeTo { dest: NodeId },
|
||||
SetVariable { variable: String, value: serde_json::Value },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Action::Navigate { .. } => "Navigate to",
|
||||
Action::Back => "Back",
|
||||
Action::ScrollTo { .. } => "Scroll to",
|
||||
Action::OpenOverlay { .. } => "Open overlay",
|
||||
Action::SwapOverlay { .. } => "Swap overlay",
|
||||
Action::CloseOverlay => "Close overlay",
|
||||
Action::OpenUrl { .. } => "Open link",
|
||||
Action::ChangeTo { .. } => "Change to",
|
||||
Action::SetVariable { .. } => "Set variable",
|
||||
}
|
||||
}
|
||||
pub fn dest(&self) -> Option<NodeId> {
|
||||
match self {
|
||||
Action::Navigate { dest } | Action::ScrollTo { dest } | Action::OpenOverlay { dest, .. } | Action::SwapOverlay { dest } | Action::ChangeTo { dest } => {
|
||||
Some(*dest)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Interaction {
|
||||
#[serde(default)]
|
||||
pub trigger: Trigger,
|
||||
pub actions: Vec<Action>,
|
||||
#[serde(default)]
|
||||
pub transition: Transition,
|
||||
#[serde(default = "default_duration")]
|
||||
pub duration_ms: u32,
|
||||
#[serde(default)]
|
||||
pub easing: Easing,
|
||||
}
|
||||
|
||||
fn default_duration() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Overflow {
|
||||
#[default]
|
||||
None,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ScrollPosition {
|
||||
#[default]
|
||||
Scrolls,
|
||||
Fixed,
|
||||
Sticky,
|
||||
}
|
||||
|
||||
/// A prototype flow's starting point on a page.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Flow {
|
||||
pub name: String,
|
||||
pub start: NodeId,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Prototype device settings on a page.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PrototypeSettings {
|
||||
pub device: String,
|
||||
pub background: figcraft_color::Color,
|
||||
}
|
||||
|
||||
impl Default for PrototypeSettings {
|
||||
fn default() -> Self {
|
||||
Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Shared styles and variables.
|
||||
|
||||
use crate::props::{Effect, LayoutGrid};
|
||||
use crate::text::TextStyle;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub type StyleId = String;
|
||||
pub type VariableId = String;
|
||||
pub type CollectionId = String;
|
||||
pub type ModeId = String;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StyleKind {
|
||||
Paint { paints: Vec<Paint> },
|
||||
Text { style: TextStyle },
|
||||
Effect { effects: Vec<Effect> },
|
||||
Grid { grids: Vec<LayoutGrid> },
|
||||
}
|
||||
|
||||
impl StyleKind {
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
StyleKind::Paint { .. } => "paint",
|
||||
StyleKind::Text { .. } => "text",
|
||||
StyleKind::Effect { .. } => "effect",
|
||||
StyleKind::Grid { .. } => "grid",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Style {
|
||||
pub id: StyleId,
|
||||
/// Slash-separated name: "Brand/Primary".
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(flatten)]
|
||||
pub kind: StyleKind,
|
||||
}
|
||||
|
||||
/// A node's links to shared styles.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct StyleRefs {
|
||||
pub fill: Option<StyleId>,
|
||||
pub stroke: Option<StyleId>,
|
||||
pub effect: Option<StyleId>,
|
||||
pub grid: Option<StyleId>,
|
||||
pub text: Option<StyleId>,
|
||||
}
|
||||
|
||||
impl StyleRefs {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == StyleRefs::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VariableType {
|
||||
Color,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
|
||||
pub enum VariableValue {
|
||||
Color(Color),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Boolean(bool),
|
||||
Alias(VariableId),
|
||||
}
|
||||
|
||||
impl VariableValue {
|
||||
pub fn type_of(&self) -> Option<VariableType> {
|
||||
match self {
|
||||
VariableValue::Color(_) => Some(VariableType::Color),
|
||||
VariableValue::Number(_) => Some(VariableType::Number),
|
||||
VariableValue::String(_) => Some(VariableType::String),
|
||||
VariableValue::Boolean(_) => Some(VariableType::Boolean),
|
||||
VariableValue::Alias(_) => None,
|
||||
}
|
||||
}
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
VariableValue::Color(c) => c.to_hex(),
|
||||
VariableValue::Number(n) => crate::text::trim_num(*n),
|
||||
VariableValue::String(s) => s.clone(),
|
||||
VariableValue::Boolean(b) => b.to_string(),
|
||||
VariableValue::Alias(a) => format!("→ {a}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mode {
|
||||
pub id: ModeId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: CollectionId,
|
||||
pub name: String,
|
||||
pub modes: Vec<Mode>,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn default_mode(&self) -> Option<&ModeId> {
|
||||
self.modes.first().map(|m| &m.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variable {
|
||||
pub id: VariableId,
|
||||
pub name: String,
|
||||
pub collection: CollectionId,
|
||||
#[serde(rename = "resolvedType")]
|
||||
pub kind: VariableType,
|
||||
/// Value per mode id.
|
||||
pub values: BTreeMap<ModeId, VariableValue>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// All variables of a document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Variables {
|
||||
pub collections: Vec<Collection>,
|
||||
pub variables: Vec<Variable>,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
pub fn get(&self, id: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.id == id || v.name == id)
|
||||
}
|
||||
|
||||
pub fn collection(&self, id: &str) -> Option<&Collection> {
|
||||
self.collections.iter().find(|c| c.id == id || c.name == id)
|
||||
}
|
||||
|
||||
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
|
||||
/// (bounded, cycles return None).
|
||||
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
|
||||
let mut cur = id.to_string();
|
||||
for _ in 0..32 {
|
||||
let var = self.get(&cur)?;
|
||||
let coll = self.collection(&var.collection)?;
|
||||
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
|
||||
match var.values.get(mode).or_else(|| var.values.values().next())? {
|
||||
VariableValue::Alias(next) => cur = next.clone(),
|
||||
v => return Some(v.clone()),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use super::*;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn page(doc: &Document) -> NodeId {
|
||||
doc.first_page().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_document_has_one_page() {
|
||||
let d = Document::new("x");
|
||||
assert_eq!(d.pages.len(), 1);
|
||||
assert_eq!(d.node(page(&d)).unwrap().name, "Page 1");
|
||||
assert!(d.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_world_transform() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "Frame 1", 100.0, 50.0, 400.0, 300.0).unwrap();
|
||||
let r = d.create(f, NodeKind::Rectangle, "Rect", 10.0, 20.0, 50.0, 40.0).unwrap();
|
||||
let b = d.world_bounds(r).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (110.0, 70.0, 160.0, 110.0));
|
||||
assert!(d.validate().is_empty());
|
||||
assert_eq!(d.top_level_frame(r), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_keeps_world_position_and_rejects_cycles() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 100.0, 100.0, 400.0, 300.0).unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 150.0, 150.0, 10.0, 10.0).unwrap();
|
||||
d.reparent(r, f, None).unwrap();
|
||||
assert_eq!(d.node(r).unwrap().parent, Some(f));
|
||||
let b = d.world_bounds(r).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (150.0, 150.0));
|
||||
assert!(matches!(d.reparent(f, f, None), Err(DocError::Cycle(..))));
|
||||
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert!(matches!(d.reparent(f, g, None), Err(DocError::Cycle(..))));
|
||||
let r2 = d.create(p, NodeKind::Rectangle, "R2", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert!(matches!(d.reparent(f, r2, None), Err(DocError::NotContainer(..))));
|
||||
assert!(d.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_within_parent() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let a = d.create(p, NodeKind::Rectangle, "A", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let b = d.create(p, NodeKind::Rectangle, "B", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let c = d.create(p, NodeKind::Rectangle, "C", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
d.reparent(a, p, Some(3)).unwrap();
|
||||
assert_eq!(d.node(p).unwrap().children, vec![b, c, a]);
|
||||
d.reorder(a, 0).unwrap();
|
||||
assert_eq!(d.node(p).unwrap().children, vec![a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_subtree_and_last_page_guard() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let r = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
d.remove(f).unwrap();
|
||||
assert!(!d.contains(r));
|
||||
assert!(d.remove(p).is_err());
|
||||
let p2 = d.add_page("Page 2");
|
||||
d.remove(p).unwrap();
|
||||
assert_eq!(d.pages, vec![p2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_fits_children() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let g = d.create(p, NodeKind::Group, "Group 1", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
d.create(g, NodeKind::Rectangle, "A", 10.0, 10.0, 20.0, 20.0).unwrap();
|
||||
let b = d.create(g, NodeKind::Rectangle, "B", 50.0, 40.0, 10.0, 10.0).unwrap();
|
||||
d.fit_to_children(g).unwrap();
|
||||
let gb = d.world_bounds(g).unwrap();
|
||||
assert_eq!((gb.x0, gb.y0, gb.x1, gb.y1), (10.0, 10.0, 60.0, 50.0));
|
||||
let bb = d.world_bounds(b).unwrap();
|
||||
assert_eq!((bb.x0, bb.y0), (50.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_remaps_ids_and_interactions() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let b = d.create(f, NodeKind::Rectangle, "B", 20.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(a).unwrap().interactions.push(proto::Interaction {
|
||||
trigger: proto::Trigger::OnClick,
|
||||
actions: vec![proto::Action::Navigate { dest: b }],
|
||||
transition: proto::Transition::Instant,
|
||||
duration_ms: 300,
|
||||
easing: proto::Easing::EaseOut,
|
||||
});
|
||||
let f2 = d.duplicate_into(f, p, None).unwrap();
|
||||
let kids = d.node(f2).unwrap().children.clone();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert!(!kids.contains(&a));
|
||||
let a2 = d.node(kids[0]).unwrap();
|
||||
let proto::Action::Navigate { dest } = a2.interactions[0].actions[0] else { panic!() };
|
||||
assert_eq!(dest, kids[1]);
|
||||
assert!(d.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_respects_z_and_clip() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let a = d.create(f, NodeKind::Rectangle, "A", 10.0, 10.0, 50.0, 50.0).unwrap();
|
||||
let b = d.create(f, NodeKind::Rectangle, "B", 30.0, 30.0, 50.0, 50.0).unwrap();
|
||||
let outside = d.create(f, NodeKind::Rectangle, "Out", 90.0, 90.0, 50.0, 50.0).unwrap();
|
||||
assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0), Some(b));
|
||||
assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0), Some(a));
|
||||
assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0), Some(outside));
|
||||
// Clipped part of `outside` isn't hit.
|
||||
assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0), None);
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_target_rules() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap();
|
||||
d.fit_to_children(g).unwrap();
|
||||
let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0).unwrap();
|
||||
assert_eq!(leaf, r);
|
||||
// Child of a top-level frame: the group.
|
||||
assert_eq!(geometry::select_target(&d, leaf, &[], false), g);
|
||||
// With the group selected, a click enters it.
|
||||
assert_eq!(geometry::select_target(&d, leaf, &[r], false), r);
|
||||
// Deep select.
|
||||
assert_eq!(geometry::select_target(&d, leaf, &[], true), r);
|
||||
// Top-level group selects as a whole.
|
||||
let g2 = d.create(p, NodeKind::Group, "G2", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
let r2 = d.create(g2, NodeKind::Rectangle, "R2", 300.0, 0.0, 20.0, 20.0).unwrap();
|
||||
d.fit_to_children(g2).unwrap();
|
||||
assert_eq!(geometry::select_target(&d, r2, &[], false), g2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_runs_restyle_and_replace() {
|
||||
let mut t = TextData::new("Hello world");
|
||||
t.restyle(0, 5, |s| s.font_style = "Bold".into());
|
||||
assert_eq!(t.runs.len(), 1);
|
||||
assert_eq!(t.style_at(0).font_style, "Bold");
|
||||
assert_eq!(t.style_at(6).font_style, "Regular");
|
||||
t.replace_range(5, 5, "!!");
|
||||
assert_eq!(t.characters, "Hello!! world");
|
||||
// Typed text at the end of a run takes its style.
|
||||
assert_eq!(t.style_at(6).font_style, "Bold");
|
||||
assert_eq!(t.spans().len(), 2);
|
||||
t.restyle(0, 100, |s| s.font_size = 20.0);
|
||||
assert!(t.spans().iter().all(|(_, _, s)| s.font_size == 20.0));
|
||||
t.restyle(0, 13, |s| s.font_style = "Regular".into());
|
||||
assert!(t.is_uniform());
|
||||
t.replace_range(0, 99, "");
|
||||
assert_eq!(t.characters, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case() {
|
||||
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
|
||||
assert_eq!(style_weight("Semi Bold Italic"), 600);
|
||||
assert_eq!(style_weight("Black"), 900);
|
||||
assert_eq!(style_weight("Regular"), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_network_paths() {
|
||||
let net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true);
|
||||
use figcraft_geom::Shape;
|
||||
assert!((net.fill_path().area().abs() - 50.0).abs() < 1e-9);
|
||||
let open = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], false);
|
||||
assert!(open.fill_path().elements().is_empty());
|
||||
assert_eq!(open.stroke_path().segments().count(), 2);
|
||||
// Round-trip a bezier path.
|
||||
let rr = figcraft_geom::rounded_rect(40.0, 20.0, [5.0; 4], 0.0);
|
||||
let n2 = VectorNetwork::from_path(&rr);
|
||||
assert!((n2.fill_path().area() - rr.area()).abs() < 1e-6);
|
||||
let mut bad = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0)], segments: vec![Segment::line(0, 5)], regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }] };
|
||||
bad.sanitize();
|
||||
assert!(bad.segments.is_empty());
|
||||
let _ = bad.fill_path();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_resolve_modes_and_aliases() {
|
||||
let mut v = Variables::default();
|
||||
v.collections.push(Collection { id: "c1".into(), name: "Theme".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }] });
|
||||
let mut vals = std::collections::BTreeMap::new();
|
||||
vals.insert("light".to_string(), VariableValue::Color(Color::WHITE));
|
||||
vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK));
|
||||
v.variables.push(Variable { id: "bg".into(), name: "bg".into(), collection: "c1".into(), kind: VariableType::Color, values: vals, description: String::new() });
|
||||
let mut alias = std::collections::BTreeMap::new();
|
||||
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
|
||||
v.variables.push(Variable { id: "surface".into(), name: "surface".into(), collection: "c1".into(), kind: VariableType::Color, values: alias, description: String::new() });
|
||||
let mut modes = std::collections::BTreeMap::new();
|
||||
assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE)));
|
||||
modes.insert("c1".to_string(), "dark".to_string());
|
||||
assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK)));
|
||||
// Cycle.
|
||||
let mut cyc = std::collections::BTreeMap::new();
|
||||
cyc.insert("light".to_string(), VariableValue::Alias("loop".into()));
|
||||
v.variables.push(Variable { id: "loop".into(), name: "loop".into(), collection: "c1".into(), kind: VariableType::Color, values: cyc, description: String::new() });
|
||||
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_and_repair() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().layout = Some(AutoLayout::default());
|
||||
d.create(f, NodeKind::Text(TextData::new("Hi")), "Hi", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.create(f, NodeKind::Star { count: 5, inner_ratio: 0.4 }, "S", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let j = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, d);
|
||||
// Corrupt: dangling child, orphan.
|
||||
let mut v: serde_json::Value = serde_json::from_str(&j).unwrap();
|
||||
v["nodes"][f.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(999));
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
assert!(!bad.validate().is_empty());
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_id_parse() {
|
||||
assert_eq!("12".parse::<NodeId>().unwrap(), NodeId(12));
|
||||
assert_eq!("0:12".parse::<NodeId>().unwrap(), NodeId(12));
|
||||
assert!("x".parse::<NodeId>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_name_increments() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
d.create(p, NodeKind::Frame, "Frame 7", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 8");
|
||||
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Text node data: characters plus a base style and styled runs.
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "unit", content = "value", rename_all = "camelCase")]
|
||||
pub enum LineHeight {
|
||||
Auto,
|
||||
Pixels(f64),
|
||||
Percent(f64),
|
||||
}
|
||||
|
||||
impl Default for LineHeight {
|
||||
fn default() -> Self {
|
||||
LineHeight::Auto
|
||||
}
|
||||
}
|
||||
|
||||
impl LineHeight {
|
||||
/// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio).
|
||||
pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 {
|
||||
match self {
|
||||
LineHeight::Auto => font_size * natural_ratio,
|
||||
LineHeight::Pixels(v) => v.max(0.0),
|
||||
LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
LineHeight::Auto => "Auto".into(),
|
||||
LineHeight::Pixels(v) => trim_num(v),
|
||||
LineHeight::Percent(p) => format!("{}%", trim_num(p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "unit", content = "value", rename_all = "camelCase")]
|
||||
pub enum LetterSpacing {
|
||||
Pixels(f64),
|
||||
Percent(f64),
|
||||
}
|
||||
|
||||
impl Default for LetterSpacing {
|
||||
fn default() -> Self {
|
||||
LetterSpacing::Percent(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl LetterSpacing {
|
||||
pub fn resolve(self, font_size: f64) -> f64 {
|
||||
match self {
|
||||
LetterSpacing::Pixels(v) => v,
|
||||
LetterSpacing::Percent(p) => font_size * p / 100.0,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
LetterSpacing::Pixels(v) => trim_num(v),
|
||||
LetterSpacing::Percent(p) => format!("{}%", trim_num(p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trim_num(v: f64) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDecoration {
|
||||
#[default]
|
||||
None,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextCase {
|
||||
#[default]
|
||||
Original,
|
||||
Upper,
|
||||
Lower,
|
||||
Title,
|
||||
SmallCaps,
|
||||
}
|
||||
|
||||
impl TextCase {
|
||||
pub fn apply(self, s: &str) -> String {
|
||||
match self {
|
||||
TextCase::Original | TextCase::SmallCaps => s.to_string(),
|
||||
TextCase::Upper => s.to_uppercase(),
|
||||
TextCase::Lower => s.to_lowercase(),
|
||||
TextCase::Title => {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut start = true;
|
||||
for ch in s.chars() {
|
||||
if start && ch.is_alphabetic() {
|
||||
out.extend(ch.to_uppercase());
|
||||
start = false;
|
||||
} else {
|
||||
out.push(ch);
|
||||
if ch.is_whitespace() {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignH {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justified,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignV {
|
||||
#[default]
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// How a text box resizes as its content changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAutoResize {
|
||||
/// Auto width: one line per paragraph, box grows both ways.
|
||||
#[default]
|
||||
WidthAndHeight,
|
||||
/// Fixed width, height grows.
|
||||
Height,
|
||||
/// Fixed size.
|
||||
None,
|
||||
/// Fixed size, truncate with an ellipsis.
|
||||
Truncate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListStyle {
|
||||
#[default]
|
||||
None,
|
||||
Bulleted,
|
||||
Numbered,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TextStyle {
|
||||
pub font_family: String,
|
||||
/// The style name, e.g. "Regular", "Bold", "Semi Bold Italic".
|
||||
pub font_style: String,
|
||||
pub font_size: f64,
|
||||
pub line_height: LineHeight,
|
||||
pub letter_spacing: LetterSpacing,
|
||||
pub decoration: TextDecoration,
|
||||
pub case: TextCase,
|
||||
/// Run fills (None = node fills).
|
||||
pub fills: Option<Vec<Paint>>,
|
||||
/// Hyperlink.
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TextStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_family: "Inter".into(),
|
||||
font_style: "Regular".into(),
|
||||
font_size: 12.0,
|
||||
line_height: LineHeight::Auto,
|
||||
letter_spacing: LetterSpacing::default(),
|
||||
decoration: TextDecoration::None,
|
||||
case: TextCase::Original,
|
||||
fills: None,
|
||||
link: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// Numeric weight implied by the style name.
|
||||
pub fn weight(&self) -> u16 {
|
||||
style_weight(&self.font_style)
|
||||
}
|
||||
pub fn italic(&self) -> bool {
|
||||
self.font_style.to_ascii_lowercase().contains("italic")
|
||||
}
|
||||
}
|
||||
|
||||
/// Weight from a style name ("Semi Bold Italic" → 600).
|
||||
pub fn style_weight(style: &str) -> u16 {
|
||||
let s = style.to_ascii_lowercase().replace([' ', '-'], "");
|
||||
let table: [(&str, u16); 10] = [
|
||||
("thin", 100),
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("light", 300),
|
||||
("medium", 500),
|
||||
("semibold", 600),
|
||||
("demibold", 600),
|
||||
("extrabold", 800),
|
||||
("ultrabold", 800),
|
||||
("black", 900),
|
||||
];
|
||||
for (k, w) in table {
|
||||
if s.contains(k) {
|
||||
return w;
|
||||
}
|
||||
}
|
||||
if s.contains("bold") {
|
||||
return 700;
|
||||
}
|
||||
if s.contains("heavy") {
|
||||
return 900;
|
||||
}
|
||||
400
|
||||
}
|
||||
|
||||
/// A styled range of characters (char indices, end exclusive).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StyleRun {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub style: TextStyle,
|
||||
}
|
||||
|
||||
/// Text node content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TextData {
|
||||
pub characters: String,
|
||||
pub style: TextStyle,
|
||||
/// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted.
|
||||
pub runs: Vec<StyleRun>,
|
||||
pub auto_resize: TextAutoResize,
|
||||
pub align_h: TextAlignH,
|
||||
pub align_v: TextAlignV,
|
||||
pub paragraph_spacing: f64,
|
||||
pub paragraph_indent: f64,
|
||||
pub list: ListStyle,
|
||||
/// Max lines when truncating (0 = as many as fit).
|
||||
pub max_lines: u32,
|
||||
/// OpenType feature toggles (e.g. "liga": false).
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
pub fn new(characters: impl Into<String>) -> Self {
|
||||
Self { characters: characters.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.characters.chars().count()
|
||||
}
|
||||
|
||||
/// The style at char index `i`.
|
||||
pub fn style_at(&self, i: usize) -> &TextStyle {
|
||||
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
|
||||
}
|
||||
|
||||
/// Spans of uniform style covering the whole text: (start, end, style).
|
||||
pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> {
|
||||
let n = self.char_count();
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0;
|
||||
let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect();
|
||||
runs.sort_by_key(|r| r.start);
|
||||
for r in runs {
|
||||
let start = r.start.max(pos);
|
||||
let end = r.end.min(n);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
if start > pos {
|
||||
out.push((pos, start, &self.style));
|
||||
}
|
||||
out.push((start, end, &r.style));
|
||||
pos = end;
|
||||
}
|
||||
if pos < n || out.is_empty() {
|
||||
out.push((pos, n, &self.style));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or
|
||||
/// empty text), splitting runs as needed and merging equal neighbours.
|
||||
pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) {
|
||||
let n = self.char_count();
|
||||
let (start, end) = (start.min(n), end.min(n));
|
||||
if n == 0 || (start == 0 && end >= n) {
|
||||
f(&mut self.style);
|
||||
for r in &mut self.runs {
|
||||
f(&mut r.style);
|
||||
}
|
||||
self.normalize();
|
||||
return;
|
||||
}
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
// Expand to per-char styles, modify, re-compress.
|
||||
let mut per: Vec<TextStyle> = (0..n).map(|i| self.style_at(i).clone()).collect();
|
||||
for s in per.iter_mut().take(end).skip(start) {
|
||||
f(s);
|
||||
}
|
||||
self.runs.clear();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let Some(s) = per.get(i) else { break };
|
||||
let mut j = i + 1;
|
||||
while per.get(j).is_some_and(|t| t == s) {
|
||||
j += 1;
|
||||
}
|
||||
if *s != self.style {
|
||||
self.runs.push(StyleRun { start: i, end: j, style: s.clone() });
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style.
|
||||
pub fn normalize(&mut self) {
|
||||
let n = self.char_count();
|
||||
let base = self.style.clone();
|
||||
self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base);
|
||||
for r in &mut self.runs {
|
||||
r.end = r.end.min(n);
|
||||
}
|
||||
self.runs.sort_by_key(|r| r.start);
|
||||
}
|
||||
|
||||
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
|
||||
pub fn replace_range(&mut self, start: usize, end: usize, text: &str) {
|
||||
let n = self.char_count();
|
||||
let (start, end) = (start.min(n), end.min(n).max(start.min(n)));
|
||||
let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len());
|
||||
let (bs, be) = (byte(start), byte(end));
|
||||
let mut s = String::with_capacity(self.characters.len() + text.len());
|
||||
s.push_str(self.characters.get(..bs).unwrap_or(""));
|
||||
s.push_str(text);
|
||||
s.push_str(self.characters.get(be..).unwrap_or(""));
|
||||
self.characters = s;
|
||||
let inserted = text.chars().count();
|
||||
let removed = end - start;
|
||||
for r in &mut self.runs {
|
||||
let shift = |p: usize| -> usize {
|
||||
if p <= start {
|
||||
p
|
||||
} else if p >= end {
|
||||
p - removed + inserted
|
||||
} else {
|
||||
start + inserted
|
||||
}
|
||||
};
|
||||
// A run touching the insertion point at its end grows with the typed text.
|
||||
let new_start = shift(r.start);
|
||||
let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) };
|
||||
r.start = new_start;
|
||||
r.end = new_end;
|
||||
}
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Whether every char shares one style (the panel shows "Mixed" otherwise).
|
||||
pub fn is_uniform(&self) -> bool {
|
||||
self.runs.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
|
||||
//! filled regions made of closed loops.
|
||||
|
||||
use figcraft_geom::{BezPath, Point};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vertex {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
/// Per-vertex corner radius (0 = sharp).
|
||||
#[serde(default)]
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
pub fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y, radius: 0.0 }
|
||||
}
|
||||
pub fn point(&self) -> Point {
|
||||
Point::new(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment from vertex `a` to `b` with tangent handles relative to each end vertex.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub a: usize,
|
||||
pub b: usize,
|
||||
#[serde(default)]
|
||||
pub ta: (f64, f64),
|
||||
#[serde(default)]
|
||||
pub tb: (f64, f64),
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn line(a: usize, b: usize) -> Self {
|
||||
Self { a, b, ta: (0.0, 0.0), tb: (0.0, 0.0) }
|
||||
}
|
||||
pub fn is_straight(&self) -> bool {
|
||||
self.ta == (0.0, 0.0) && self.tb == (0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WindingRule {
|
||||
#[default]
|
||||
NonZero,
|
||||
EvenOdd,
|
||||
}
|
||||
|
||||
/// A filled region: loops of segment indices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Region {
|
||||
pub loops: Vec<Vec<usize>>,
|
||||
#[serde(default)]
|
||||
pub winding: WindingRule,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct VectorNetwork {
|
||||
pub vertices: Vec<Vertex>,
|
||||
pub segments: Vec<Segment>,
|
||||
#[serde(default)]
|
||||
pub regions: Vec<Region>,
|
||||
}
|
||||
|
||||
impl VectorNetwork {
|
||||
/// A network from a polyline / polygon.
|
||||
pub fn from_points(points: &[Point], closed: bool) -> Self {
|
||||
let vertices: Vec<Vertex> = points.iter().map(|p| Vertex::new(p.x, p.y)).collect();
|
||||
let n = vertices.len();
|
||||
let mut segments: Vec<Segment> = (1..n).map(|i| Segment::line(i - 1, i)).collect();
|
||||
let mut regions = Vec::new();
|
||||
if closed && n > 2 {
|
||||
segments.push(Segment::line(n - 1, 0));
|
||||
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
|
||||
}
|
||||
Self { vertices, segments, regions }
|
||||
}
|
||||
|
||||
/// A network from a bezier path: each subpath becomes a chain (closed subpaths a loop + region).
|
||||
pub fn from_path(path: &BezPath) -> Self {
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
let mut net = VectorNetwork::default();
|
||||
let mut loop_segs: Vec<usize> = Vec::new();
|
||||
let mut first_v: Option<usize> = None;
|
||||
let mut cur_v: Option<usize> = None;
|
||||
let mut all_loops: Vec<Vec<usize>> = Vec::new();
|
||||
let push_v = |net: &mut VectorNetwork, p: Point| -> usize {
|
||||
net.vertices.push(Vertex::new(p.x, p.y));
|
||||
net.vertices.len() - 1
|
||||
};
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
loop_segs.clear();
|
||||
let v = push_v(&mut net, p);
|
||||
first_v = Some(v);
|
||||
cur_v = Some(v);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let Some(a) = cur_v else { continue };
|
||||
let b = push_v(&mut net, p);
|
||||
net.segments.push(Segment::line(a, b));
|
||||
loop_segs.push(net.segments.len() - 1);
|
||||
cur_v = Some(b);
|
||||
}
|
||||
PathEl::QuadTo(c, p) => {
|
||||
let Some(a) = cur_v else { continue };
|
||||
let Some(pa) = net.vertices.get(a).map(Vertex::point) else { continue };
|
||||
let c1 = pa + (c - pa) * (2.0 / 3.0);
|
||||
let c2 = p + (c - p) * (2.0 / 3.0);
|
||||
let b = push_v(&mut net, p);
|
||||
net.segments.push(Segment { a, b, ta: (c1.x - pa.x, c1.y - pa.y), tb: (c2.x - p.x, c2.y - p.y) });
|
||||
loop_segs.push(net.segments.len() - 1);
|
||||
cur_v = Some(b);
|
||||
}
|
||||
PathEl::CurveTo(c1, c2, p) => {
|
||||
let Some(a) = cur_v else { continue };
|
||||
let Some(pa) = net.vertices.get(a).map(Vertex::point) else { continue };
|
||||
let b = push_v(&mut net, p);
|
||||
net.segments.push(Segment { a, b, ta: (c1.x - pa.x, c1.y - pa.y), tb: (c2.x - p.x, c2.y - p.y) });
|
||||
loop_segs.push(net.segments.len() - 1);
|
||||
cur_v = Some(b);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
let (Some(a), Some(f)) = (cur_v, first_v) else { continue };
|
||||
let close_pt = net.vertices.get(a).map(Vertex::point);
|
||||
let first_pt = net.vertices.get(f).map(Vertex::point);
|
||||
if close_pt.is_some() && close_pt == first_pt && a != f {
|
||||
// Merge the duplicate end vertex into the first one.
|
||||
for s in net.segments.iter_mut() {
|
||||
if s.b == a {
|
||||
s.b = f;
|
||||
}
|
||||
}
|
||||
if a + 1 == net.vertices.len() {
|
||||
net.vertices.pop();
|
||||
}
|
||||
} else if a != f {
|
||||
net.segments.push(Segment::line(a, f));
|
||||
loop_segs.push(net.segments.len() - 1);
|
||||
}
|
||||
if !loop_segs.is_empty() {
|
||||
all_loops.push(std::mem::take(&mut loop_segs));
|
||||
}
|
||||
cur_v = Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !all_loops.is_empty() {
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
|
||||
}
|
||||
net
|
||||
}
|
||||
|
||||
fn seg_points(&self, s: &Segment) -> Option<(Point, Point, Point, Point)> {
|
||||
let a = self.vertices.get(s.a)?.point();
|
||||
let b = self.vertices.get(s.b)?.point();
|
||||
Some((a, Point::new(a.x + s.ta.0, a.y + s.ta.1), Point::new(b.x + s.tb.0, b.y + s.tb.1), b))
|
||||
}
|
||||
|
||||
fn push_seg(&self, path: &mut BezPath, s: &Segment, reversed: bool) {
|
||||
let Some((a, c1, c2, b)) = self.seg_points(s) else { return };
|
||||
let (c1, c2, b) = if reversed { (c2, c1, a) } else { (c1, c2, b) };
|
||||
if s.is_straight() {
|
||||
path.line_to(b);
|
||||
} else {
|
||||
path.curve_to(c1, c2, b);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill path: every region's loops.
|
||||
pub fn fill_path(&self) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
for region in &self.regions {
|
||||
for lp in ®ion.loops {
|
||||
self.append_loop(&mut path, lp);
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn append_loop(&self, path: &mut BezPath, lp: &[usize]) {
|
||||
// Walk the loop, orienting each segment to continue from the previous end vertex.
|
||||
let segs: Vec<&Segment> = lp.iter().filter_map(|i| self.segments.get(*i)).collect();
|
||||
let Some(first) = segs.first() else { return };
|
||||
let start_v = match segs.get(1) {
|
||||
Some(second) if first.a == second.a || first.a == second.b => first.b,
|
||||
_ => first.a,
|
||||
};
|
||||
let Some(start) = self.vertices.get(start_v) else { return };
|
||||
path.move_to(start.point());
|
||||
let mut cur = start_v;
|
||||
for s in segs {
|
||||
if s.a == cur {
|
||||
self.push_seg(path, s, false);
|
||||
cur = s.b;
|
||||
} else {
|
||||
self.push_seg(path, s, true);
|
||||
cur = s.a;
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
}
|
||||
|
||||
/// The stroke path: segments chained into maximal open runs (vertices of degree 2 continue a
|
||||
/// run); closed loops come out closed.
|
||||
pub fn stroke_path(&self) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
let n = self.segments.len();
|
||||
let mut used = vec![false; n];
|
||||
let degree = |v: usize| self.segments.iter().filter(|s| s.a == v || s.b == v).count();
|
||||
let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
|
||||
self.segments.iter().enumerate().find_map(|(i, s)| {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
return None;
|
||||
}
|
||||
if s.a == v {
|
||||
Some((i, false))
|
||||
} else if s.b == v {
|
||||
Some((i, true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
// Start runs at endpoints / junctions first, then whatever remains (pure loops).
|
||||
let mut starts: Vec<usize> = (0..self.vertices.len()).filter(|v| degree(*v) != 2).collect();
|
||||
starts.extend((0..self.vertices.len()).filter(|v| degree(*v) == 2));
|
||||
for v0 in starts {
|
||||
while let Some((si, rev)) = next_from(v0, &used) {
|
||||
let Some(start) = self.vertices.get(v0) else { break };
|
||||
path.move_to(start.point());
|
||||
let mut cur;
|
||||
let mut seg = Some((si, rev));
|
||||
while let Some((i, r)) = seg {
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(s) = self.segments.get(i) else { break };
|
||||
self.push_seg(&mut path, s, r);
|
||||
cur = if r { s.a } else { s.b };
|
||||
if cur == v0 {
|
||||
path.close_path();
|
||||
break;
|
||||
}
|
||||
seg = if degree(cur) == 2 { next_from(cur, &used) } else { None };
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Translate/scale every vertex and handle (used when the node is resized).
|
||||
pub fn scale(&mut self, sx: f64, sy: f64) {
|
||||
for v in &mut self.vertices {
|
||||
v.x *= sx;
|
||||
v.y *= sy;
|
||||
}
|
||||
for s in &mut self.segments {
|
||||
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
|
||||
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&mut self, dx: f64, dy: f64) {
|
||||
for v in &mut self.vertices {
|
||||
v.x += dx;
|
||||
v.y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the curves.
|
||||
pub fn bounds(&self) -> Option<figcraft_geom::Rect> {
|
||||
use figcraft_geom::Shape;
|
||||
let p = self.stroke_path();
|
||||
if p.elements().is_empty() { None } else { Some(p.bounding_box()) }
|
||||
}
|
||||
|
||||
/// Remove segments that reference missing vertices, and regions referencing missing segments.
|
||||
pub fn sanitize(&mut self) {
|
||||
let nv = self.vertices.len();
|
||||
let keep: Vec<bool> = self.segments.iter().map(|s| s.a < nv && s.b < nv).collect();
|
||||
if keep.iter().all(|k| *k) {
|
||||
let ns = self.segments.len();
|
||||
for r in &mut self.regions {
|
||||
r.loops.retain(|l| l.iter().all(|i| *i < ns));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut remap = vec![usize::MAX; self.segments.len()];
|
||||
let mut out = Vec::new();
|
||||
for (i, s) in self.segments.iter().enumerate() {
|
||||
if keep.get(i).copied().unwrap_or(false) {
|
||||
if let Some(m) = remap.get_mut(i) {
|
||||
*m = out.len();
|
||||
}
|
||||
out.push(*s);
|
||||
}
|
||||
}
|
||||
self.segments = out;
|
||||
for r in &mut self.regions {
|
||||
r.loops.retain(|l| l.iter().all(|i| remap.get(*i).is_some_and(|m| *m != usize::MAX)));
|
||||
for l in &mut r.loops {
|
||||
for i in l.iter_mut() {
|
||||
*i = remap.get(*i).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "figcraft-geom"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft geometry: affine transforms, shape path builders (rounded/smoothed corners, polygons, stars, arcs)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,324 @@
|
||||
//! FigCraft geometry.
|
||||
//!
|
||||
//! Re-exports [`kurbo`] and adds the shape builders a design tool needs: rectangles with independent
|
||||
//! corner radii and corner smoothing (the "squircle" continuous-curvature corner), regular polygons
|
||||
//! and stars (with rounded vertices), and ellipse arcs / rings. Coordinates are y-down.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub use kurbo;
|
||||
pub use kurbo::{Affine, BezPath, Point, Rect, Shape, Size, Vec2};
|
||||
|
||||
use kurbo::ParamCurve;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
mod transform;
|
||||
pub use transform::*;
|
||||
|
||||
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
|
||||
pub type Radii = [f64; 4];
|
||||
|
||||
fn finite(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A rectangle path at the origin with per-corner radii and corner smoothing (0..=1, Figma's
|
||||
/// "Corner smoothing"; 0.6 is the iOS look).
|
||||
///
|
||||
/// Radii are clamped so adjacent corners never overlap; when smoothing doesn't fit, it is reduced
|
||||
/// first, then the radius.
|
||||
pub fn rounded_rect(width: f64, height: f64, radii: Radii, smoothing: f64) -> BezPath {
|
||||
let w = finite(width).max(0.0);
|
||||
let h = finite(height).max(0.0);
|
||||
let mut path = BezPath::new();
|
||||
if w <= 0.0 && h <= 0.0 {
|
||||
return path;
|
||||
}
|
||||
let s = finite(smoothing).clamp(0.0, 1.0);
|
||||
let max_r = w.min(h) / 2.0;
|
||||
let radii = radii.map(|r| finite(r).clamp(0.0, max_r));
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((w, 0.0));
|
||||
path.line_to((w, h));
|
||||
path.line_to((0.0, h));
|
||||
path.close_path();
|
||||
return path;
|
||||
}
|
||||
// Corners clockwise starting top-right, each: (corner, incoming edge direction, outgoing).
|
||||
let corners: [(Point, Vec2, Vec2, f64); 4] = [
|
||||
(Point::new(w, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), radii[1]),
|
||||
(Point::new(w, h), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0), radii[2]),
|
||||
(Point::new(0.0, h), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0), radii[3]),
|
||||
(Point::new(0.0, 0.0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), radii[0]),
|
||||
];
|
||||
let first = corner_profile(radii[0], s, max_r);
|
||||
// Start on the top edge just after the top-left corner.
|
||||
path.move_to((first.p, 0.0));
|
||||
for (c, din, dout, r) in corners {
|
||||
let prof = corner_profile(r, s, max_r);
|
||||
append_corner(&mut path, c, din, dout, &prof);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// The smoothed-corner profile along one corner (lengths measured from the corner point).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CornerProfile {
|
||||
r: f64,
|
||||
/// Total length the corner occupies along each edge.
|
||||
p: f64,
|
||||
a: f64,
|
||||
b: f64,
|
||||
c: f64,
|
||||
d: f64,
|
||||
}
|
||||
|
||||
fn corner_profile(r: f64, smoothing: f64, max_p: f64) -> CornerProfile {
|
||||
let r = r.max(0.0);
|
||||
if r <= 0.0 {
|
||||
return CornerProfile { r: 0.0, p: 0.0, a: 0.0, b: 0.0, c: 0.0, d: 0.0 };
|
||||
}
|
||||
let mut s = smoothing;
|
||||
// Reduce smoothing first when the smoothed corner doesn't fit along the edge.
|
||||
if (1.0 + s) * r > max_p {
|
||||
s = (max_p / r - 1.0).clamp(0.0, s);
|
||||
}
|
||||
let r = r.min(max_p);
|
||||
let p = ((1.0 + s) * r).min(max_p);
|
||||
let arc_measure = 90.0 * (1.0 - s);
|
||||
let arc_section_length = (arc_measure / 2.0).to_radians().sin() * r * std::f64::consts::SQRT_2;
|
||||
let angle_alpha = (90.0 - arc_measure) / 2.0;
|
||||
let p3_to_p4 = r * (angle_alpha / 2.0).to_radians().tan();
|
||||
let angle_beta = 45.0 * s;
|
||||
let c = p3_to_p4 * angle_beta.to_radians().cos();
|
||||
let d = c * angle_beta.to_radians().tan();
|
||||
let b = ((p - arc_section_length - c - d) / 3.0).max(0.0);
|
||||
let a = 2.0 * b;
|
||||
CornerProfile { r, p, a, b, c, d }
|
||||
}
|
||||
|
||||
fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof: &CornerProfile) {
|
||||
// position = corner - back * din + side * dout
|
||||
let at = |back: f64, side: f64| corner - din * back + dout * side;
|
||||
if prof.r <= 0.0 {
|
||||
path.line_to(corner);
|
||||
return;
|
||||
}
|
||||
let CornerProfile { r, p, a, b, c, d } = *prof;
|
||||
let start = at(p, 0.0);
|
||||
path.line_to(start);
|
||||
let p3 = at(p - a - b - c, d);
|
||||
if a + b + c > 1e-9 {
|
||||
path.curve_to(at(p - a, 0.0), at(p - a - b, 0.0), p3);
|
||||
}
|
||||
let p4 = at(d, p - a - b - c);
|
||||
let center = at(r, r);
|
||||
let a0 = (p3 - center).atan2();
|
||||
let a1 = (p4 - center).atan2();
|
||||
let mut sweep = a1 - a0;
|
||||
while sweep <= -PI {
|
||||
sweep += TAU;
|
||||
}
|
||||
while sweep > PI {
|
||||
sweep -= TAU;
|
||||
}
|
||||
if sweep.abs() > 1e-9 {
|
||||
let arc = kurbo::Arc::new(center, (r, r), a0, sweep, 0.0);
|
||||
arc.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
|
||||
}
|
||||
let end = at(0.0, p);
|
||||
if a + b + c > 1e-9 {
|
||||
path.curve_to(at(0.0, p - a - b), at(0.0, p - a), end);
|
||||
} else {
|
||||
path.line_to(end);
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertices of a regular polygon with `count` sides inscribed in the `width`×`height` box,
|
||||
/// first vertex at the top centre (Figma's polygon).
|
||||
pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec<Point> {
|
||||
let n = count.clamp(3, 120);
|
||||
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n);
|
||||
Point::new(rx + rx * t.cos(), ry + ry * t.sin())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius.
|
||||
pub fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec<Point> {
|
||||
let n = count.clamp(3, 120);
|
||||
let k = finite(inner_ratio).clamp(0.001, 1.0);
|
||||
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
|
||||
(0..n * 2)
|
||||
.map(|i| {
|
||||
let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n);
|
||||
let f = if i % 2 == 0 { 1.0 } else { k };
|
||||
Point::new(rx + rx * f * t.cos(), ry + ry * f * t.sin())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A closed polygon through `points` with every vertex rounded by `radius` (clamped so arcs fit).
|
||||
pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
let n = points.len();
|
||||
if n < 2 {
|
||||
return path;
|
||||
}
|
||||
let radius = finite(radius).max(0.0);
|
||||
if radius <= 0.0 || n < 3 {
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
path.move_to(*p);
|
||||
} else {
|
||||
path.line_to(*p);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
return path;
|
||||
}
|
||||
// For each vertex: the tangent points on its two edges and the bezier between them.
|
||||
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let (Some(prev), Some(cur), Some(next)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else {
|
||||
continue;
|
||||
};
|
||||
let v1 = *prev - *cur;
|
||||
let v2 = *next - *cur;
|
||||
let (l1, l2) = (v1.hypot(), v2.hypot());
|
||||
if l1 < 1e-9 || l2 < 1e-9 {
|
||||
corners.push((*cur, *cur, *cur, *cur));
|
||||
continue;
|
||||
}
|
||||
let (u1, u2) = (v1 / l1, v2 / l2);
|
||||
let cos = u1.dot(u2).clamp(-1.0, 1.0);
|
||||
let angle = cos.acos();
|
||||
if angle < 1e-6 || (PI - angle) < 1e-6 {
|
||||
corners.push((*cur, *cur, *cur, *cur));
|
||||
continue;
|
||||
}
|
||||
let mut t = radius / (angle / 2.0).tan();
|
||||
let max_t = (l1.min(l2)) / 2.0;
|
||||
let mut r = radius;
|
||||
if t > max_t {
|
||||
r = r * max_t / t;
|
||||
t = max_t;
|
||||
}
|
||||
let a = *cur + u1 * t;
|
||||
let b = *cur + u2 * t;
|
||||
// Cubic arc approximation: handle length k*r with k = 4/3 tan(theta/4), theta = PI - angle.
|
||||
let theta = PI - angle;
|
||||
let k = 4.0 / 3.0 * (theta / 4.0).tan() * r;
|
||||
let c1 = a - u1 * k;
|
||||
let c2 = b - u2 * k;
|
||||
corners.push((a, c1, c2, b));
|
||||
}
|
||||
for (i, (a, c1, c2, b)) in corners.iter().enumerate() {
|
||||
if i == 0 {
|
||||
path.move_to(*a);
|
||||
} else {
|
||||
path.line_to(*a);
|
||||
}
|
||||
path.curve_to(*c1, *c2, *b);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
|
||||
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
|
||||
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
|
||||
let (rx, ry) = (finite(width).max(0.0) / 2.0, finite(height).max(0.0) / 2.0);
|
||||
let c = Point::new(rx, ry);
|
||||
let sweep = finite(sweep).clamp(-TAU, TAU);
|
||||
let start = finite(start);
|
||||
let inner = finite(inner).clamp(0.0, 1.0);
|
||||
let full = sweep.abs() >= TAU - 1e-9;
|
||||
let mut path = BezPath::new();
|
||||
if full && inner <= 0.0 {
|
||||
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
|
||||
}
|
||||
if full {
|
||||
// A ring: outer clockwise, inner counter-clockwise.
|
||||
path.extend(kurbo::Ellipse::new(c, (rx, ry), 0.0).path_elements(0.01));
|
||||
let inner_path = kurbo::Ellipse::new(c, (rx * inner, ry * inner), 0.0).to_path(0.01);
|
||||
path.extend(inner_path.reverse_subpaths());
|
||||
return path;
|
||||
}
|
||||
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
|
||||
path.move_to(c + Vec2::new(rx * start.cos(), ry * start.sin()));
|
||||
outer.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
|
||||
if inner > 0.0 {
|
||||
let end = start + sweep;
|
||||
path.line_to(c + Vec2::new(rx * inner * end.cos(), ry * inner * end.sin()));
|
||||
let back = kurbo::Arc::new(c, (rx * inner, ry * inner), end, -sweep, 0.0);
|
||||
back.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
|
||||
} else {
|
||||
path.line_to(c);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Trait helper: reverse every subpath of a path.
|
||||
pub trait ReverseSubpaths {
|
||||
fn reverse_subpaths(&self) -> BezPath;
|
||||
}
|
||||
|
||||
impl ReverseSubpaths for BezPath {
|
||||
fn reverse_subpaths(&self) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for seg_path in split_subpaths(self) {
|
||||
let segs: Vec<kurbo::PathSeg> = seg_path.segments().collect();
|
||||
let Some(last) = segs.last() else { continue };
|
||||
out.move_to(last.end());
|
||||
for s in segs.iter().rev() {
|
||||
match s.reverse() {
|
||||
kurbo::PathSeg::Line(l) => out.line_to(l.p1),
|
||||
kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||||
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a path into its subpaths.
|
||||
pub fn split_subpaths(path: &BezPath) -> Vec<BezPath> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = BezPath::new();
|
||||
for el in path.elements() {
|
||||
if let kurbo::PathEl::MoveTo(_) = el
|
||||
&& !cur.elements().is_empty()
|
||||
{
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.push(*el);
|
||||
}
|
||||
if !cur.elements().is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bounding box of a set of points (`None` when empty).
|
||||
pub fn points_bounds(points: impl IntoIterator<Item = Point>) -> Option<Rect> {
|
||||
let mut it = points.into_iter();
|
||||
let first = it.next()?;
|
||||
Some(it.fold(Rect::from_points(first, first), |r, p| r.union_pt(p)))
|
||||
}
|
||||
|
||||
/// The axis-aligned bounds of `rect` after `xf`.
|
||||
pub fn transform_rect_bbox(xf: Affine, rect: Rect) -> Rect {
|
||||
let pts = [Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)];
|
||||
points_bounds(pts.into_iter().map(|p| xf * p)).unwrap_or(Rect::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
fn area(p: &BezPath) -> f64 {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_rect_area() {
|
||||
let p = rounded_rect(100.0, 50.0, [0.0; 4], 0.0);
|
||||
assert!((area(&p) - 5000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_area_matches_circle_corners() {
|
||||
let r = 10.0;
|
||||
let p = rounded_rect(100.0, 50.0, [r; 4], 0.0);
|
||||
let expected = 5000.0 - 4.0 * r * r + PI * r * r;
|
||||
assert!((area(&p) - expected).abs() < 0.5, "{} vs {}", area(&p), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_radius_is_capsule_and_bounds_hold() {
|
||||
let p = rounded_rect(100.0, 40.0, [1000.0; 4], 0.0);
|
||||
let b = p.bounding_box();
|
||||
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_stays_in_bounds_and_reduces_area_less_than_sharp() {
|
||||
for s in [0.0, 0.3, 0.6, 1.0] {
|
||||
let p = rounded_rect(200.0, 120.0, [24.0, 0.0, 40.0, 8.0], s);
|
||||
let b = p.bounding_box();
|
||||
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 200.0 + 1e-6 && b.y1 <= 120.0 + 1e-6, "s={s} {b:?}");
|
||||
assert!(area(&p) < 200.0 * 120.0);
|
||||
}
|
||||
// More smoothing removes more area at the same radius.
|
||||
let a0 = area(&rounded_rect(200.0, 200.0, [40.0; 4], 0.0));
|
||||
let a1 = area(&rounded_rect(200.0, 200.0, [40.0; 4], 1.0));
|
||||
assert!(a1 < a0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs_dont_panic() {
|
||||
let _ = rounded_rect(f64::NAN, -1.0, [f64::INFINITY; 4], f64::NAN);
|
||||
let _ = rounded_rect(0.0, 0.0, [5.0; 4], 0.5);
|
||||
let _ = polygon_points(0.0, 0.0, 0);
|
||||
let _ = star_points(10.0, 10.0, 1, -3.0);
|
||||
let _ = rounded_polygon(&[], 4.0);
|
||||
let _ = rounded_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0);
|
||||
let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_first_vertex_top_center() {
|
||||
let pts = polygon_points(100.0, 100.0, 3);
|
||||
assert_eq!(pts.len(), 3);
|
||||
assert!((pts[0].x - 50.0).abs() < 1e-9 && pts[0].y.abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_has_twice_the_points() {
|
||||
assert_eq!(star_points(100.0, 100.0, 5, 0.38).len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_polygon_shrinks_area() {
|
||||
let pts = polygon_points(100.0, 100.0, 6);
|
||||
let sharp = area(&rounded_polygon(&pts, 0.0));
|
||||
let round = area(&rounded_polygon(&pts, 10.0));
|
||||
assert!(round < sharp && round > sharp * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_ring_area() {
|
||||
let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0));
|
||||
assert!((full - PI * 2500.0).abs() < 2.0);
|
||||
let ring = ellipse(100.0, 100.0, 0.0, TAU, 0.5);
|
||||
// Winding area of a ring with a reversed inner contour: outer - inner.
|
||||
let a = ring.area().abs();
|
||||
assert!((a - PI * (2500.0 - 625.0)).abs() < 5.0, "{a}");
|
||||
let half = area(&ellipse(100.0, 100.0, 0.0, PI, 0.0));
|
||||
assert!((half - PI * 1250.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_roundtrip() {
|
||||
for deg in [0.0, 30.0, -45.0, 90.0, 179.0] {
|
||||
let xf = from_position_rotation(10.0, 20.0, deg);
|
||||
assert!((rotation_degrees(xf) - deg).abs() < 1e-9, "{deg}");
|
||||
assert_eq!(translation(xf), Point::new(10.0, 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rotation_keeps_center() {
|
||||
let xf = from_position_rotation(10.0, 20.0, 0.0);
|
||||
let r = set_rotation_about_center(xf, 100.0, 50.0, 90.0);
|
||||
let c0 = xf * Point::new(50.0, 25.0);
|
||||
let c1 = r * Point::new(50.0, 25.0);
|
||||
assert!((c0 - c1).hypot() < 1e-9);
|
||||
assert!((rotation_degrees(r) - 90.0).abs() < 1e-9);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Decomposing and composing the 2×3 node transforms the design panel shows as X, Y and rotation.
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
|
||||
/// Rotation in degrees (counter-clockwise on screen, as Figma's rotation field shows it) of `xf`.
|
||||
pub fn rotation_degrees(xf: Affine) -> f64 {
|
||||
let [a, b, ..] = xf.as_coeffs();
|
||||
// y-down: positive Figma rotation turns counter-clockwise on screen, i.e. a negative math angle.
|
||||
let deg = -b.atan2(a).to_degrees();
|
||||
let deg = if deg.abs() < 1e-9 { 0.0 } else { deg };
|
||||
if deg <= -180.0 { deg + 360.0 } else { deg }
|
||||
}
|
||||
|
||||
/// The translation part of `xf`.
|
||||
pub fn translation(xf: Affine) -> Point {
|
||||
let c = xf.as_coeffs();
|
||||
Point::new(c[4], c[5])
|
||||
}
|
||||
|
||||
/// Replace the translation of `xf`.
|
||||
pub fn with_translation(xf: Affine, p: Point) -> Affine {
|
||||
let c = xf.as_coeffs();
|
||||
Affine::new([c[0], c[1], c[2], c[3], p.x, p.y])
|
||||
}
|
||||
|
||||
/// Whether the linear part is a mirror (negative determinant).
|
||||
pub fn is_flipped(xf: Affine) -> bool {
|
||||
xf.determinant() < 0.0
|
||||
}
|
||||
|
||||
/// A transform with the given translation and rotation (degrees, Figma convention).
|
||||
pub fn from_position_rotation(x: f64, y: f64, degrees: f64) -> Affine {
|
||||
Affine::translate(Vec2::new(x, y)) * Affine::rotate(-degrees.to_radians())
|
||||
}
|
||||
|
||||
/// Change the rotation of `xf` to `degrees` about the centre of a `w`×`h` box in its local space,
|
||||
/// keeping that centre in place and keeping any flip.
|
||||
pub fn set_rotation_about_center(xf: Affine, w: f64, h: f64, degrees: f64) -> Affine {
|
||||
let center_local = Point::new(w / 2.0, h / 2.0);
|
||||
let center = xf * center_local;
|
||||
let flip = if is_flipped(xf) { Affine::FLIP_X } else { Affine::IDENTITY };
|
||||
let rot = Affine::rotate(-degrees.to_radians());
|
||||
let lin = rot * flip;
|
||||
// Solve translation so that lin * center_local + t = center.
|
||||
let mapped = lin * center_local;
|
||||
let t = center - mapped;
|
||||
let c = lin.as_coeffs();
|
||||
Affine::new([c[0], c[1], c[2], c[3], t.x, t.y])
|
||||
}
|
||||
|
||||
/// Rotate `xf` by `degrees` (Figma convention) about the point `pivot` (parent space).
|
||||
pub fn rotate_about(xf: Affine, pivot: Point, degrees: f64) -> Affine {
|
||||
Affine::translate(pivot.to_vec2()) * Affine::rotate(-degrees.to_radians()) * Affine::translate(-pivot.to_vec2()) * xf
|
||||
}
|
||||
|
||||
/// Round a value to `digits` decimals for display (avoids `-0` and float noise).
|
||||
pub fn round_to(v: f64, digits: i32) -> f64 {
|
||||
let f = 10f64.powi(digits);
|
||||
let r = (v * f).round() / f;
|
||||
if r == 0.0 { 0.0 } else { r }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by FigCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within FigCraft itself, its documentation and its official builds, to identify FigCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of FigCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than FigCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of FigCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on FigCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1,2 @@
|
||||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||