M0.1-M0.3: workspace, licences, geom/color/doc crates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 12:01:57 -04:00
co-authored by Claude Opus 5.5
parent a66b3d2882
commit f7a68c864b
40 changed files with 5002 additions and 0 deletions
+4
View File
@@ -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 }
+7
View File
@@ -1,3 +1,10 @@
*~
plan/
plans/
/target/
/target-*/
/dist/
/corpus/
/log/
.DS_Store
*.profraw
Generated
+215
View File
@@ -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"
+79
View File
@@ -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
+177
View File
@@ -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
+21
View File
@@ -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.
+21
View File
@@ -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.
+5
View File
@@ -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
+18
View File
@@ -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.
+16
View File
@@ -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 }
+107
View File
@@ -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)
}
}
+100
View File
@@ -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),
}
}
+392
View File
@@ -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;
+67
View File
@@ -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]);
}
+16
View File
@@ -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 }
+220
View File
@@ -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),
]
}
+689
View File
@@ -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;
+429
View File
@@ -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
}
}
+463
View File
@@ -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,
}
+264
View File
@@ -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) }
}
}
+166
View File
@@ -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
}
}
+253
View File
@@ -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");
}
+383
View File
@@ -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()
}
}
+314
View File
@@ -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 &region.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);
}
}
}
}
}
+13
View File
@@ -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 }
+324
View File
@@ -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;
+102
View File
@@ -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);
}
+61
View File
@@ -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 }
}
+42
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+4
View File
@@ -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

+2
View File
@@ -0,0 +1,2 @@
max_width = 150
use_small_heuristics = "Max"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
serde_json = { workspace = true }
+1
View File
@@ -0,0 +1 @@
fn main() {}