mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-21 14:44:18 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44104b67ca | ||
|
|
e2d75c7ba6 | ||
|
|
59f4b753dd | ||
|
|
8bc117eb8b | ||
|
|
78105cc695 | ||
|
|
ff021918a6 | ||
|
|
45f806d307 | ||
|
|
fe71ee2d75 | ||
|
|
74ec2751ed | ||
|
|
eb9e064254 | ||
|
|
5925e6339a | ||
|
|
b45bb3dd54 | ||
|
|
875f98d211 | ||
|
|
c21ac5ca02 | ||
|
|
3987fdfca7 | ||
|
|
9439087946 | ||
|
|
a92f0264c2 | ||
|
|
1824829931 | ||
|
|
a5c0c29b25 | ||
|
|
adbe0566a6 | ||
|
|
675b759a37 | ||
|
|
d58888a95c | ||
|
|
e0bef14664 | ||
|
|
a3e7dc9e41 | ||
|
|
f381513cbe | ||
|
|
f5011c7454 | ||
|
|
ab9b054164 | ||
|
|
cb84ffcfa1 | ||
|
|
0c9a7a89a0 | ||
|
|
03a2da13ba | ||
|
|
83a5978d6e | ||
|
|
c6677df848 | ||
|
|
d783e1565c | ||
|
|
56235323a1 | ||
|
|
322b4852e2 | ||
|
|
ebce8f12df | ||
|
|
76b6d31646 | ||
|
|
52a94d5a84 | ||
|
|
4408e9fedb | ||
|
|
0756fb0948 | ||
|
|
3c6d2b371e | ||
|
|
695f7c0d4b | ||
|
|
755f2b9158 | ||
|
|
03bd5e5a53 | ||
|
|
f329f6271d | ||
|
|
bbe026607f | ||
|
|
e065467a65 | ||
|
|
73cbba8b8d | ||
|
|
609c98f458 | ||
|
|
b024756e9f | ||
|
|
e7cdf154ca | ||
|
|
14f804cdee | ||
|
|
2cd7f156fb | ||
|
|
4d1581f3da | ||
|
|
4f5f573ded | ||
|
|
7ceaf900d6 | ||
|
|
f19b33c219 | ||
|
|
0650c24a09 | ||
|
|
45025a8d53 | ||
|
|
6f711b94d5 | ||
|
|
be669cbfc5 | ||
|
|
240af4613d | ||
|
|
1541de79ca | ||
|
|
924d762640 | ||
|
|
9c278e0688 | ||
|
|
154f09400e | ||
|
|
730ccfcf8b | ||
|
|
773964cb80 | ||
|
|
a97c92481c | ||
|
|
e5be5c21d0 | ||
|
|
f85fe0effb | ||
|
|
775016b455 | ||
|
|
29d783eac1 | ||
|
|
4f0d98107b | ||
|
|
844d78e58f | ||
|
|
49355a08bc | ||
|
|
cda55f690a | ||
|
|
2a1885d2de | ||
|
|
10d78516f6 | ||
|
|
e6db3421ce | ||
|
|
0ad0b7ab0f | ||
|
|
ab6f62128e | ||
|
|
84efde6ba0 | ||
|
|
833523e29e | ||
|
|
c8432aee37 | ||
|
|
cb1512d9fe | ||
|
|
3b3eba6ae1 | ||
|
|
8f2e2740c3 | ||
|
|
fae70b006c | ||
|
|
ef7cdf5927 | ||
|
|
21291ab387 | ||
|
|
2cf7ad1ad1 | ||
|
|
de01edb28a | ||
|
|
0a1c4cdd87 | ||
|
|
5fc175a5ef | ||
|
|
ba047ee5f7 | ||
|
|
ecb7ac1fc6 | ||
|
|
f9e289410d | ||
|
|
a1738c15d4 | ||
|
|
9a5c5459bd | ||
|
|
c895f64063 | ||
|
|
4e5f5e608b | ||
|
|
cc4836abb7 | ||
|
|
56cfb79d24 | ||
|
|
240d8d4891 | ||
|
|
b13072b77c | ||
|
|
e5471be4d9 | ||
|
|
1c7b02a6fe | ||
|
|
c7df7a56e8 | ||
|
|
07a49d447d | ||
|
|
a719b22e1b | ||
|
|
3f45472e15 | ||
|
|
45cabc1c3c | ||
|
|
59534c1cbb | ||
|
|
2d85d8818e | ||
|
|
ab0ddf0eae | ||
|
|
6433b6ca3a | ||
|
|
cdef620083 | ||
|
|
b3a380cfa0 | ||
|
|
2dcceea8e1 | ||
|
|
0a3628f62f | ||
|
|
571ac32362 | ||
|
|
58066428c6 | ||
|
|
9c20027778 | ||
|
|
dfa7bd12d9 | ||
|
|
3f3888d040 | ||
|
|
fface52206 | ||
|
|
951d1705e3 | ||
|
|
4cba355f71 | ||
|
|
ba2a5bfaed | ||
|
|
2c715c9fe3 | ||
|
|
5e24e59bd3 | ||
|
|
1cfeaabb2d | ||
|
|
3884cf9704 | ||
|
|
6807e757af | ||
|
|
32d012f6cc | ||
|
|
7d58ce4f9d | ||
|
|
e321c7179b | ||
|
|
84d5d8797f | ||
|
|
52b63e2198 | ||
|
|
398c6316d9 | ||
|
|
23dbf5d214 | ||
|
|
af2618c9e4 | ||
|
|
5ac70a9b07 | ||
|
|
bbd9f51096 | ||
|
|
7d6eb3246e | ||
|
|
963922beaf | ||
|
|
b008e83658 | ||
|
|
79c4647665 | ||
|
|
4b56ce44a1 | ||
|
|
b7ea047d81 | ||
|
|
8b67c4a943 | ||
|
|
7aa25698d2 | ||
|
|
b4fef5dc98 | ||
|
|
7d4ae444b6 | ||
|
|
bc3351eacc | ||
|
|
bc6ffd9c1e | ||
|
|
d8e876b721 | ||
|
|
80088bbf25 | ||
|
|
04bf2dcab9 | ||
|
|
76058fb1b5 | ||
|
|
acb017e1f1 | ||
|
|
da197ba75c | ||
|
|
bac132d8d4 | ||
|
|
1f6a76afa8 | ||
|
|
48263b339d | ||
|
|
cb0a7d9b31 | ||
|
|
0b94055cfe | ||
|
|
fe8c0f16f0 | ||
|
|
bdcaf0da46 | ||
|
|
18594e8228 | ||
|
|
ec3d0076d1 | ||
|
|
60ed162fe8 | ||
|
|
06be77c967 | ||
|
|
44370f9e62 | ||
|
|
7c72fa5fba | ||
|
|
a85d167b03 | ||
|
|
50336e8021 | ||
|
|
1362b40588 | ||
|
|
3d890d93fd | ||
|
|
441c543a66 | ||
|
|
de6f153032 | ||
|
|
84c21707b9 | ||
|
|
b94311c97b | ||
|
|
3854155f18 | ||
|
|
27a6993a8d | ||
|
|
747d7096fb | ||
|
|
36338753af | ||
|
|
c299686326 | ||
|
|
36d7f3b3b6 | ||
|
|
b597b5bbe0 | ||
|
|
5973245909 | ||
|
|
294ba818a7 | ||
|
|
254fb1605b | ||
|
|
e013061c9a | ||
|
|
1f73cdf7bc | ||
|
|
06cf9d5dea | ||
|
|
dff3a18584 | ||
|
|
77c58db7b6 | ||
|
|
e84073e535 | ||
|
|
10eba8be69 | ||
|
|
e407202107 | ||
|
|
04ca2f066d | ||
|
|
30203e60a1 | ||
|
|
a5ea3a8002 | ||
|
|
dda52d0b20 | ||
|
|
9acfd1ea8a | ||
|
|
f009f4f3e5 | ||
|
|
1adca4f484 | ||
|
|
d532e6d341 | ||
|
|
1d2f50ec71 | ||
|
|
9f31471042 | ||
|
|
1267efbcf9 | ||
|
|
e5b1f0c191 | ||
|
|
6f95f1cf09 | ||
|
|
096544db40 | ||
|
|
0d4b597983 | ||
|
|
37a8aa4946 | ||
|
|
f761ae2585 | ||
|
|
bcf8732d67 | ||
|
|
2c4d0bac92 | ||
|
|
6a1c8eda46 | ||
|
|
764d04926e | ||
|
|
1047c82bd3 | ||
|
|
91107c89d2 | ||
|
|
5d0b966548 | ||
|
|
3cd2ae2f12 | ||
|
|
68d8a9c755 | ||
|
|
b1abc73e16 | ||
|
|
bdf54007a0 | ||
|
|
9aad69b8f6 | ||
|
|
63c17ec13e | ||
|
|
d4df4646ee | ||
|
|
0701340720 | ||
|
|
84833d10ce | ||
|
|
cde39698ff | ||
|
|
b0056a127d | ||
|
|
1cea163b11 | ||
|
|
3677a16474 | ||
|
|
ae41ddb9a5 | ||
|
|
9be3ddab31 | ||
|
|
5846b00da3 | ||
|
|
1565c96123 | ||
|
|
1613ef99f6 | ||
|
|
819efd465f | ||
|
|
d55a5245fb | ||
|
|
96592e9e27 | ||
|
|
6041bb3c62 | ||
|
|
54ee0b6251 | ||
|
|
67448e9245 | ||
|
|
0ec23d90bc | ||
|
|
ed93963d73 | ||
|
|
3f729b6481 | ||
|
|
5b77e422ab | ||
|
|
bc441d102e | ||
|
|
6ee04f3805 | ||
|
|
a2abc1316b | ||
|
|
61f278f894 | ||
|
|
a09828188a | ||
|
|
906da779ff | ||
|
|
f0667469e5 | ||
|
|
69e73564d2 | ||
|
|
e2547c933d | ||
|
|
d47346b237 | ||
|
|
94e3cb7bf7 | ||
|
|
b29f1394c2 | ||
|
|
c2313099d4 | ||
|
|
f8dbbc25b2 | ||
|
|
7a1e7c1955 | ||
|
|
6f3df95ad4 | ||
|
|
1b5d8abdcf | ||
|
|
8d1bbbf35e | ||
|
|
96fe96de77 | ||
|
|
3e93c71772 | ||
|
|
52e4fa52c5 | ||
|
|
410fa6b84f | ||
|
|
d65094ac5d | ||
|
|
47c0374858 | ||
|
|
5fdea8749c | ||
|
|
649d3c83db | ||
|
|
c2f2a49a32 | ||
|
|
5a12b47e4d | ||
|
|
9674310a58 | ||
|
|
b9a37cbd05 | ||
|
|
69890c37ff | ||
|
|
e8bd0ac410 | ||
|
|
5fd553cc8b | ||
|
|
923c49eda0 | ||
|
|
a304cdb589 | ||
|
|
5660a15b75 | ||
|
|
14ec7f472f | ||
|
|
ba8c1e042c | ||
|
|
53569a883a | ||
|
|
b9fa08f9e6 | ||
|
|
a5a9371979 | ||
|
|
89b749b828 | ||
|
|
fbdfe5fc82 | ||
|
|
a810044e5a | ||
|
|
061e905724 | ||
|
|
de3b94804d | ||
|
|
787592ff0e | ||
|
|
1e4ebba1cc | ||
|
|
d80024eff6 | ||
|
|
7fbd215e97 | ||
|
|
4bcd14c9c2 | ||
|
|
6bce3578ca | ||
|
|
156bd632e6 | ||
|
|
f43c6ff47d | ||
|
|
5ebef8282a | ||
|
|
87686e81f2 | ||
|
|
32834a9135 | ||
|
|
a994f1e416 | ||
|
|
656562d114 | ||
|
|
2927ff6e85 | ||
|
|
240b0ca881 | ||
|
|
cc0e7c4c67 | ||
|
|
e76a9c1a45 | ||
|
|
4e0cbfeede | ||
|
|
4444450a44 | ||
|
|
92a9b91255 | ||
|
|
fc644b9a9e | ||
|
|
4fdb3e1f9b | ||
|
|
d2625ec108 | ||
|
|
0782248cff | ||
|
|
6e4fb6e4e7 | ||
|
|
82a78e3d58 | ||
|
|
ecd01ad6f1 | ||
|
|
2305ebb931 | ||
|
|
17952be94a | ||
|
|
0f39505d12 | ||
|
|
31734a0dcb | ||
|
|
c5f6443e0c | ||
|
|
0d387fb16c | ||
|
|
153bcb2ec7 | ||
|
|
d5682167ec | ||
|
|
975db53e30 | ||
|
|
a06950b30c | ||
|
|
a437b9ae63 | ||
|
|
ffc7e7272e | ||
|
|
528f94c4cf | ||
|
|
0e7cb4afd0 | ||
|
|
c1a823b34d | ||
|
|
24638c409b | ||
|
|
bda3c14f72 | ||
|
|
9ce5ffd244 | ||
|
|
5569ae97a6 | ||
|
|
97d133d9d4 | ||
|
|
8712be97b0 | ||
|
|
494343c274 | ||
|
|
acee6cccd8 | ||
|
|
89c1448777 | ||
|
|
781d68a971 | ||
|
|
7c8e772b46 | ||
|
|
2b0d71dbea | ||
|
|
15babbcacc | ||
|
|
55df023b65 | ||
|
|
078e67a386 | ||
|
|
bc1ae08252 | ||
|
|
cdc4ecc67e | ||
|
|
0c42504069 | ||
|
|
31eafe3f8d | ||
|
|
2c1391cf40 | ||
|
|
ae663f53aa | ||
|
|
dd7991cb20 | ||
|
|
03b2960f4d | ||
|
|
da8e6af7e8 | ||
|
|
bf6692ae82 | ||
|
|
923a3ddc09 | ||
|
|
0d2eeed995 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
Generated
+245
-274
File diff suppressed because it is too large.
Load diff
+9
-3
@@ -15,6 +15,9 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b77
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
@@ -22,8 +25,9 @@ vectorcraft-doc = { path = "crates/doc" }
|
||||
vectorcraft-pathops = { path = "crates/pathops" }
|
||||
vectorcraft-brush = { path = "crates/brush" }
|
||||
vectorcraft-trace = { path = "crates/trace" }
|
||||
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
|
||||
# library crate never forces them on an embedder that supplies its fonts at run time.
|
||||
# Without its default `bundled-fonts`: a crate that wants the fonts asks for them (the apps and
|
||||
# vectorcraft-testkit through the engine's `bundled-fonts`), so a library crate never forces them on
|
||||
# an embedder that supplies its fonts at run time.
|
||||
vectorcraft-text = { path = "crates/text", default-features = false }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
@@ -32,7 +36,9 @@ vectorcraft-svg = { path = "crates/svg" }
|
||||
vectorcraft-pdf = { path = "crates/pdf" }
|
||||
vectorcraft-format = { path = "crates/format" }
|
||||
vectorcraft-tools = { path = "crates/tools" }
|
||||
vectorcraft-engine = { path = "crates/engine" }
|
||||
# Without its default `bundled-fonts`, as vectorcraft-text: each crate that wants the fonts asks for
|
||||
# them, so an embedder can build without them (docs/development.md › Leaving out the bundled fonts).
|
||||
vectorcraft-engine = { path = "crates/engine", default-features = false }
|
||||
vectorcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
vectorcraft-mcp = { path = "crates/mcp" }
|
||||
vectorcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
@@ -20,11 +20,14 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f?style=flat-square">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555?style=flat-square">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_vectorcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/vectorcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -250,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp under Sketch) and the Effect Gallery;
|
||||
- most of the raster effects (9 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+10
-4
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 46/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 54/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 81% | 8–14 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 8–14 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -123,17 +123,21 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 h.
|
||||
5. **Raster effects** (G2) alongside: the 10 remaining filters (7 Artistic, 2 Sketch, 1 more) and the Effect Gallery, 8–14 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
|
||||
|
||||
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
||||
|
||||
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. |
|
||||
| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. |
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
@@ -185,6 +189,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
|
||||
@@ -16,7 +16,7 @@ workspace = true
|
||||
[dependencies]
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-svg = { workspace = true }
|
||||
|
||||
@@ -96,8 +96,9 @@ USAGE:
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
|
||||
even where that draws differently from its PDF part or page (which can be older), as long as
|
||||
it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
|
||||
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
|
||||
|
||||
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
|
||||
@@ -17,9 +17,12 @@ accessibility = ["eframe/accesskit"]
|
||||
windows7 = ["eframe/glow"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
|
||||
# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
|
||||
craft-window = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n};
|
||||
|
||||
/// The accessory's height and the room around and between its label and menu, in points.
|
||||
const HEIGHT: f64 = 38.0;
|
||||
@@ -70,7 +68,8 @@ define_class!(
|
||||
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
|
||||
/// two remain, and the panel is then left to rfd.
|
||||
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
|
||||
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
let named: Vec<Filter> =
|
||||
filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect();
|
||||
if named.len() < 2 { Vec::new() } else { named }
|
||||
}
|
||||
|
||||
@@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
|
||||
/// the first one to a name typed without an extension, and a name typed with another of them is
|
||||
/// saved in that format.
|
||||
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
|
||||
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
|
||||
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> {
|
||||
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice());
|
||||
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
|
||||
first.iter().chain(others).copied().collect()
|
||||
first.iter().chain(others).map(String::as_str).collect()
|
||||
}
|
||||
|
||||
/// Set `panel`'s allowed file types ([`allowed_types`]).
|
||||
@@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, s
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
fn filters() -> Vec<Filter> {
|
||||
FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])])
|
||||
}
|
||||
|
||||
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
|
||||
/// named type, or one for every file, gets no menu, and rfd shows it.
|
||||
#[test]
|
||||
fn save_panels_with_several_formats_get_the_menu() {
|
||||
let save = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
|
||||
assert!(save.len() >= 2, "{save:?}");
|
||||
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
|
||||
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
|
||||
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
|
||||
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
|
||||
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
|
||||
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
|
||||
assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..]));
|
||||
assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg")));
|
||||
assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2);
|
||||
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty());
|
||||
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty());
|
||||
assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index, "{name}");
|
||||
assert_eq!(initial_format(name, &filters()), index, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +220,7 @@ mod tests {
|
||||
("photo.png", "photo.svg"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
|
||||
assert_eq!(filename(name, "svg", &filters()), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,19 +228,19 @@ mod tests {
|
||||
/// type. The others stay allowed.
|
||||
#[test]
|
||||
fn the_chosen_format_is_the_first_allowed_type() {
|
||||
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
|
||||
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]);
|
||||
assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]);
|
||||
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
|
||||
for (label, extensions) in &filters {
|
||||
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
|
||||
let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters);
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
-23
@@ -144,11 +144,7 @@ impl App {
|
||||
// again on its document when it answers.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.app.file_dialog_open() {
|
||||
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
|
||||
// again on its document when it answers.
|
||||
if !self.app.file_dialog_open() {
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
@@ -293,7 +289,7 @@ type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
trait DialogSettings: Sized {
|
||||
fn parent(self, window: &winit::window::Window) -> Self;
|
||||
fn title(self, title: &str) -> Self;
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self;
|
||||
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
|
||||
fn folder(self, folder: &str) -> Self;
|
||||
fn file_name(self, name: &str) -> Self;
|
||||
}
|
||||
@@ -307,7 +303,7 @@ macro_rules! dialog_settings {
|
||||
fn title(self, title: &str) -> Self {
|
||||
self.set_title(title)
|
||||
}
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self {
|
||||
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
|
||||
self.add_filter(name, exts)
|
||||
}
|
||||
fn folder(self, folder: &str) -> Self {
|
||||
@@ -333,10 +329,9 @@ fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
|
||||
}
|
||||
PickRequest::Folder => return d,
|
||||
};
|
||||
let d = pick
|
||||
.filters
|
||||
.iter()
|
||||
.fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts));
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
|
||||
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
|
||||
});
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.folder(folder),
|
||||
None => d,
|
||||
@@ -629,13 +624,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
///
|
||||
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
|
||||
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
|
||||
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
|
||||
/// before the window opens; a missing or unreadable value keeps the default.
|
||||
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
|
||||
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
|
||||
/// `false` (the default). This preference forces the desktop/native frame back on.
|
||||
fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
|
||||
/// decided and the user's System Title Bar preference.
|
||||
///
|
||||
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
|
||||
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
|
||||
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
|
||||
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
|
||||
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
|
||||
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
|
||||
/// title bar; the preference always forces the native frame back on.
|
||||
///
|
||||
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
|
||||
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
|
||||
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
|
||||
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
|
||||
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
|
||||
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
|
||||
}
|
||||
|
||||
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
|
||||
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
|
||||
/// Windows draws its own caption unless the user opted into the system title bar.
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
|
||||
CUSTOM_TITLEBAR && !opted_into_system
|
||||
}
|
||||
|
||||
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
|
||||
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
|
||||
/// Title Bar preference. Call once at startup, before [`native_options`].
|
||||
///
|
||||
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
|
||||
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
|
||||
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
|
||||
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
|
||||
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
|
||||
let opted_into_system = opted_into_system_title_bar(saved);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
|
||||
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
|
||||
// present, the same two conditions the launch path checks below — that run is effectively
|
||||
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
|
||||
// the probe's own decision (app-drawn on GNOME) must stand.
|
||||
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
|
||||
if choice.overridden().is_none() && forced_to_x11 {
|
||||
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
|
||||
}
|
||||
}
|
||||
custom_titlebar_from(choice.frame(), opted_into_system)
|
||||
}
|
||||
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
@@ -1048,17 +1103,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
// decorations on Windows and Linux; macOS always has them.
|
||||
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
|
||||
use craft_window::{Frame, Layout};
|
||||
|
||||
// The System Title Bar preference (Preferences › User Interface) is read the same way on
|
||||
// every platform; a missing or unreadable value is `false`.
|
||||
assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||||
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
|
||||
assert!(!super::opted_into_system_title_bar(Some(&off)));
|
||||
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
|
||||
assert!(!super::custom_titlebar(Some(&on)));
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
if super::CUSTOM_TITLEBAR {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
assert!(super::opted_into_system_title_bar(Some(&on)));
|
||||
|
||||
// The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
|
||||
// bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
|
||||
// decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
|
||||
// our own title bar — unless the user opted into the system title bar, which forces the
|
||||
// native frame back on.
|
||||
let app = Frame::App(Layout::default());
|
||||
let desktop = Frame::Desktop;
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
|
||||
assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
|
||||
assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
|
||||
assert!(!super::custom_titlebar_from(&desktop, true));
|
||||
}
|
||||
// macOS and Windows keep today's behavior, independent of the probed frame: the pref still
|
||||
// forces the native frame back on where a custom one would otherwise be drawn.
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
|
||||
assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
|
||||
assert!(!super::custom_titlebar_from(&desktop, true));
|
||||
}
|
||||
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
|
||||
@@ -326,7 +326,11 @@ mod tests {
|
||||
let pick = FilePick {
|
||||
name: "Untitled-1.vectorcraft".into(),
|
||||
folder: Some("/home/me/Art".into()),
|
||||
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
|
||||
filters: vec![
|
||||
FilePick::filter("VectorCraft", &["vectorcraft"]),
|
||||
FilePick::filter("SVG", &["svg"]),
|
||||
FilePick::filter("PDF-compatible .ai", &["ai"]),
|
||||
],
|
||||
};
|
||||
SaveRequest::new(&pick, "x11:3a0000f".into())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
// Long enough for the binary to start on a loaded machine (the workspace's other tests run
|
||||
// meanwhile), short enough to catch a desktop that started instead of exiting.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let timed_out = loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break false;
|
||||
|
||||
@@ -140,3 +140,13 @@ sync_github_name = false
|
||||
|
||||
[people.KaboomFox]
|
||||
sync_github_name = true
|
||||
|
||||
[people.odysseexp]
|
||||
real_name = "Harley Paiva"
|
||||
sync_github_name = true
|
||||
|
||||
[people.addibble]
|
||||
sync_github_name = true
|
||||
|
||||
[people.alpha-rock]
|
||||
display_name = "Aditya Sisodiya"
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Node};
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
use crate::Colorization;
|
||||
|
||||
@@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
|
||||
}
|
||||
|
||||
/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so
|
||||
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone.
|
||||
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is
|
||||
/// recolored like paths: its characters' solid fills and strokes as well as its own.
|
||||
pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
|
||||
let key = match (mode, stroke.color()) {
|
||||
(Colorization::None, _) | (_, None) => None,
|
||||
@@ -65,6 +66,12 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) {
|
||||
for r in &mut t.runs {
|
||||
map_paint(&mut r.style.fill, mode, k);
|
||||
map_paint(&mut r.style.stroke, mode, k);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
walk(Arc::make_mut(c), mode, key);
|
||||
@@ -88,8 +95,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
if let Paint::Solid { color: c, .. } = &mut r.style.fill {
|
||||
*c = c.lerp(color, amount);
|
||||
for p in [&mut r.style.fill, &mut r.style.stroke] {
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*c = c.lerp(color, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +128,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
|
||||
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
|
||||
/// its default.
|
||||
/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider
|
||||
/// than 0.
|
||||
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
|
||||
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
|
||||
for it in &n.appearance.items {
|
||||
@@ -131,6 +142,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
|
||||
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
for r in &t.runs {
|
||||
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
|
||||
for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) {
|
||||
let order = seen.len();
|
||||
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(c, seen);
|
||||
}
|
||||
|
||||
+29
-7
@@ -413,6 +413,8 @@ impl Bristle {
|
||||
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
|
||||
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
|
||||
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
|
||||
/// The most characters `brush.new` and `brush.options` take for a new bristle brush name.
|
||||
pub const MAX_NAME_CHARS: usize = 31;
|
||||
}
|
||||
|
||||
impl Default for Bristle {
|
||||
@@ -456,6 +458,31 @@ impl Brush {
|
||||
}
|
||||
}
|
||||
|
||||
/// A brush definition from JSON (a stored library entry or a command's params), its values
|
||||
/// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
|
||||
/// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
|
||||
/// black when they use no solid color.
|
||||
pub fn from_json(mut v: serde_json::Value) -> Result<Brush, serde_json::Error> {
|
||||
let keyless = v.get("colorization").is_some_and(|c| {
|
||||
c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null)
|
||||
});
|
||||
if keyless {
|
||||
// Read the art with the colorization field left out.
|
||||
let mut plain = v.clone();
|
||||
if let Some(o) = plain.as_object_mut() {
|
||||
o.remove("colorization");
|
||||
}
|
||||
let b: Brush = serde_json::from_value(plain)?;
|
||||
let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK);
|
||||
if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) {
|
||||
c.insert("key".into(), serde_json::to_value(key)?);
|
||||
}
|
||||
}
|
||||
let mut b: Brush = serde_json::from_value(v)?;
|
||||
b.sanitize();
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Keep every value of the definition within its range (definitions come from files and
|
||||
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
|
||||
pub fn sanitize(&mut self) {
|
||||
@@ -517,14 +544,9 @@ pub fn library(doc: &Document) -> Vec<Brush> {
|
||||
}
|
||||
|
||||
/// Parse a stored library value (malformed entries are skipped, values kept within their
|
||||
/// ranges — see [`Brush::sanitize`]).
|
||||
/// ranges; see [`Brush::from_json`]).
|
||||
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
|
||||
let parse = |b: &serde_json::Value| {
|
||||
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
|
||||
b.sanitize();
|
||||
Some(b)
|
||||
};
|
||||
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
|
||||
v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Store `lib` as the document's brush library.
|
||||
|
||||
@@ -308,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() {
|
||||
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
|
||||
fn type_art(fill: Paint, stroke: Paint) -> Node {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
|
||||
Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
|
||||
}
|
||||
|
||||
/// The characters' fills and strokes of every piece of type in `nodes`.
|
||||
fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
|
||||
#[test]
|
||||
fn scatter_colorization_recolors_type() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
|
||||
let mut path = rect_art(10.0, 4.0);
|
||||
path.appearance.set_fill(red.clone());
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
|
||||
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
|
||||
assert!(!out.is_empty());
|
||||
let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
|
||||
assert_ne!(path_fill, red);
|
||||
let paints = run_paints(&out);
|
||||
assert_eq!(paints.len(), out.len());
|
||||
assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
|
||||
// Black type takes the stroke color itself.
|
||||
let black = Paint::solid(Color::BLACK);
|
||||
let b = Brush {
|
||||
name: "s".into(),
|
||||
kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
|
||||
};
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
|
||||
assert!(!out.is_empty());
|
||||
assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
|
||||
}
|
||||
|
||||
/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
|
||||
#[test]
|
||||
fn stain_mixes_type_strokes_too() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let mut art = type_art(red.clone(), red);
|
||||
tint_node(&mut art, &blue, 1.0);
|
||||
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_defaults_store_and_find() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
@@ -557,11 +614,46 @@ fn art_colors_list_the_most_used_first() {
|
||||
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
|
||||
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
|
||||
assert_eq!(art_colors([&g]), vec![blue, red]);
|
||||
// Type counts its characters' fills and drawn strokes.
|
||||
let text = type_art(Paint::solid(red), Paint::solid(red));
|
||||
let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
|
||||
assert_eq!(art_colors([&g]), vec![red, blue]);
|
||||
let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
|
||||
t.runs[0].style.stroke_width = 0.0;
|
||||
let text = Node::new(NodeId(0), NodeKind::Text(t));
|
||||
let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
|
||||
assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
|
||||
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
|
||||
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
|
||||
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
|
||||
}
|
||||
|
||||
/// A Hue Shift definition without a key color takes the art's most used color, or black when the
|
||||
/// art has no solid color; a key that is given is kept.
|
||||
#[test]
|
||||
fn hue_shift_without_a_key_takes_the_most_used_art_color() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]);
|
||||
let lib = parse_library(&serde_json::json!([
|
||||
{"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}},
|
||||
{"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}},
|
||||
{"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}},
|
||||
{"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}},
|
||||
]));
|
||||
let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
("s", Colorization::HueShift { key: blue }),
|
||||
("a", Colorization::HueShift { key: blue }),
|
||||
("p", Colorization::HueShift { key: red }),
|
||||
("n", Colorization::HueShift { key: Color::BLACK }),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
|
||||
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
|
||||
#[test]
|
||||
|
||||
@@ -862,7 +862,8 @@ impl<'a> Scene<'a> {
|
||||
let Some(rgb) = self.paint_rgb(paint) else { continue };
|
||||
let style = self.style(rgb, 1.0, None, None);
|
||||
let text_style = self.text_style(&cs.font_family);
|
||||
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
|
||||
// The height is that of the size the layout draws: Shrink Text to Fit scales the size by `layout.fit_scale`.
|
||||
let height = cs.size * layout.fit_scale * cs.v_scale / 100.0 * CAP_HEIGHT * across;
|
||||
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
|
||||
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
|
||||
let p = self.pt(t.xf * origin);
|
||||
|
||||
@@ -456,6 +456,27 @@ fn type_is_text_or_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
#[test]
|
||||
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
|
||||
let mut d = text_doc("Plan A, plan B and plan C", 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let mut fit = 1.0;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) };
|
||||
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
|
||||
fit = lay.fit_scale;
|
||||
}
|
||||
let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out));
|
||||
for e in &e {
|
||||
assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_ascii_is_escaped_before_2007() {
|
||||
let d = text_doc("Größe", 12.0);
|
||||
|
||||
@@ -113,11 +113,11 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
|
||||
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
|
||||
/// #1584: every built-in profile (RGB, CMYK and gray) has each tag on a 4-byte boundary and a size
|
||||
/// that is a multiple of 4 (ICC.1 §7.1.2), and still reads back as the same space.
|
||||
#[test]
|
||||
fn written_profiles_align_their_tags() {
|
||||
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
|
||||
fn builtin_profiles_align_their_sizes_and_tags() {
|
||||
for name in BUILTIN_RGB.into_iter().chain(BUILTIN_CMYK).chain([GRAY]) {
|
||||
let icc = super::icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
assert_eq!(be(0), icc.len(), "{name}: header size");
|
||||
|
||||
@@ -188,7 +188,7 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
Paint::Solid { color, .. } => css_color(color, 1.0),
|
||||
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
|
||||
let stops: Vec<String> =
|
||||
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect();
|
||||
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect();
|
||||
match g.gradient.kind {
|
||||
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
|
||||
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
|
||||
@@ -371,7 +371,9 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = line.strip_prefix("Name:") {
|
||||
lib.name = n.trim().to_string();
|
||||
if !n.trim().is_empty() {
|
||||
lib.name = n.trim().to_string();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped.
|
||||
@@ -648,6 +650,14 @@ mod tests {
|
||||
use crate::{Gradient, GradientPaint, GradientStop};
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn gpl_blank_name_keeps_fallback() {
|
||||
let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n";
|
||||
assert_eq!(read(blank, "fallback").unwrap().name, "fallback");
|
||||
let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n";
|
||||
assert_eq!(read(named, "fallback").unwrap().name, "Warm");
|
||||
}
|
||||
|
||||
/// A color book's string: a `u32` count of UTF-16 code units, then the units.
|
||||
fn acb_str(out: &mut Vec<u8>, s: &str) {
|
||||
let units: Vec<u16> = s.encode_utf16().collect();
|
||||
@@ -805,6 +815,32 @@ mod tests {
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preserves_fractional_gradient_stop_positions() {
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
let lib = SwatchLibrary {
|
||||
name: "Positions".into(),
|
||||
swatches: vec![Swatch {
|
||||
name: "Ramp".into(),
|
||||
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
kind,
|
||||
stops: vec![
|
||||
GradientStop::new(0.0, Color::BLACK),
|
||||
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
|
||||
GradientStop::new(0.12890625, Color::WHITE),
|
||||
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
|
||||
],
|
||||
}))),
|
||||
global: false,
|
||||
spot: false,
|
||||
}],
|
||||
groups: vec![],
|
||||
};
|
||||
let text = written(&lib, PaletteFormat::Css);
|
||||
assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_counts_the_swatches_each_format_writes() {
|
||||
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
|
||||
|
||||
+104
-10
@@ -44,15 +44,45 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
|
||||
x + (y - x) * t
|
||||
}
|
||||
|
||||
fn solid_gradient(c: Color, like: &Gradient) -> Gradient {
|
||||
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
|
||||
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
|
||||
let mut g = like.clone();
|
||||
for s in &mut g.stops {
|
||||
s.color = c;
|
||||
s.opacity = 1.0;
|
||||
s.swatch = swatch.map(str::to_string);
|
||||
s.tint = tint;
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
|
||||
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
|
||||
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
|
||||
let stops = &g.stops;
|
||||
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
|
||||
let Some(first) = stops.first() else { return (None, 1.0) };
|
||||
if t <= first.offset {
|
||||
return link(first);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.offset {
|
||||
let span = (b.offset - a.offset).max(1e-6);
|
||||
let u = (t - a.offset) / span;
|
||||
let m = a.midpoint.clamp(0.01, 0.99);
|
||||
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
|
||||
return match (&a.swatch, &b.swatch) {
|
||||
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
|
||||
_ if v <= 0.0 => link(a),
|
||||
_ if v >= 1.0 => link(b),
|
||||
_ => (None, 1.0),
|
||||
};
|
||||
}
|
||||
}
|
||||
stops.last().map_or((None, 1.0), link)
|
||||
}
|
||||
|
||||
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
|
||||
fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
|
||||
let mut out = g.clone();
|
||||
@@ -60,7 +90,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
|
||||
.iter()
|
||||
.map(|&o| {
|
||||
let (color, opacity) = g.sample_with(o, lerp_color);
|
||||
GradientStop { opacity, ..GradientStop::new(o, color) }
|
||||
let (swatch, tint) = link_at(g, o);
|
||||
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
|
||||
})
|
||||
.collect();
|
||||
out
|
||||
@@ -106,10 +137,19 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
|
||||
.stops
|
||||
.iter()
|
||||
.zip(&gb.stops)
|
||||
.map(|(sa, sb)| GradientStop {
|
||||
opacity: lerp_f32(sa.opacity, sb.opacity, t),
|
||||
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
|
||||
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
|
||||
.map(|(sa, sb)| {
|
||||
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
|
||||
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
|
||||
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
|
||||
_ => (None, 1.0),
|
||||
};
|
||||
GradientStop {
|
||||
opacity: lerp_f32(sa.opacity, sb.opacity, t),
|
||||
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
|
||||
swatch,
|
||||
tint,
|
||||
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.angle = a.angle + (b.angle - a.angle) * t as f64;
|
||||
@@ -131,17 +171,21 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
|
||||
/// solid↔gradient; otherwise switch halfway.
|
||||
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
|
||||
match (a, b) {
|
||||
// Two tints of one swatch blend into tints of that swatch (a spot stays a spot on export).
|
||||
(Paint::Solid { color: x, swatch: Some(sa), tint: ta }, Paint::Solid { color: y, swatch: Some(sb), tint: tb }) if sa == sb => {
|
||||
Paint::Solid { color: lerp_color(x, y, t), swatch: Some(sa.clone()), tint: lerp_f32(*ta, *tb, t) }
|
||||
}
|
||||
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
|
||||
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
|
||||
Some(g) => Paint::Gradient(Box::new(g)),
|
||||
None => (if t < 0.5 { a } else { b }).clone(),
|
||||
},
|
||||
(Paint::Solid { color, .. }, Paint::Gradient(g)) => {
|
||||
let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
|
||||
(Paint::Solid { color, swatch, tint }, Paint::Gradient(g)) => {
|
||||
let ga = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
|
||||
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
|
||||
}
|
||||
(Paint::Gradient(g), Paint::Solid { color, .. }) => {
|
||||
let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
|
||||
(Paint::Gradient(g), Paint::Solid { color, swatch, tint }) => {
|
||||
let gb = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
|
||||
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
|
||||
}
|
||||
_ => (if t < 0.5 { a } else { b }).clone(),
|
||||
@@ -1114,6 +1158,56 @@ mod tests {
|
||||
assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_between_tints_of_one_swatch_keeps_the_link() {
|
||||
let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint };
|
||||
let Paint::Solid { swatch, tint, .. } = lerp_paint(&tinted(0.2), &tinted(1.0), 0.5) else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Spot"));
|
||||
assert!((tint - 0.6).abs() < 1e-6, "{tint}");
|
||||
let other = Paint::Solid { color: Color::BLACK, swatch: Some("Other".into()), tint: 1.0 };
|
||||
assert!(matches!(lerp_paint(&tinted(0.2), &other, 0.5), Paint::Solid { swatch: None, .. }));
|
||||
|
||||
// Gradient stops on tints of one swatch stay linked too; a process solid unlinks them.
|
||||
let spot_gradient = |tint: f32| {
|
||||
let mut stop = GradientStop::new(0.0, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
|
||||
stop.swatch = Some("Spot".into());
|
||||
stop.tint = tint;
|
||||
let stops = vec![stop.clone(), GradientStop { offset: 1.0, ..stop }];
|
||||
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
|
||||
};
|
||||
let Paint::Gradient(m) = lerp_paint(&spot_gradient(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
|
||||
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
|
||||
let Paint::Gradient(m) = lerp_paint(&tinted(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
|
||||
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
|
||||
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &spot_gradient(1.0), 0.5) else { panic!() };
|
||||
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampled_gradient_stops_keep_the_swatch_link() {
|
||||
let spot = |offset: f32, tint: f32| {
|
||||
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
|
||||
stop.swatch = Some("Spot".into());
|
||||
stop.tint = tint;
|
||||
stop
|
||||
};
|
||||
let paint = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })));
|
||||
let two = paint(vec![spot(0.0, 0.2), spot(1.0, 1.0)]);
|
||||
let three = paint(vec![spot(0.0, 0.2), spot(0.5, 0.6), spot(1.0, 1.0)]);
|
||||
let Paint::Gradient(m) = lerp_paint(&two, &three, 0.5) else { panic!() };
|
||||
assert_eq!(m.gradient.stops.len(), 3);
|
||||
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
|
||||
let tints: Vec<f32> = m.gradient.stops.iter().map(|s| s.tint).collect();
|
||||
assert!((tints[1] - 0.6).abs() < 1e-6 && (tints[2] - 1.0).abs() < 1e-6, "{tints:?}");
|
||||
|
||||
// Spot on one side, process on the other: the stops are unlinked.
|
||||
let mut plain = spot(0.5, 1.0);
|
||||
plain.swatch = None;
|
||||
let mixed = paint(vec![spot(0.0, 1.0), plain, spot(1.0, 1.0)]);
|
||||
let Paint::Gradient(m) = lerp_paint(&two, &mixed, 0.5) else { panic!() };
|
||||
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_with_different_stop_counts_interpolate() {
|
||||
let g = |stops: &[(f32, Color)]| {
|
||||
|
||||
+179
-34
@@ -7,11 +7,68 @@ use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
use crate::node::{LiveShape, Node, NodeKind};
|
||||
use crate::selection::AnchorRef;
|
||||
|
||||
/// Stored corner radii as document lengths for cutting and display (fractions when `relative`).
|
||||
pub fn resolve_stored_radii(base: &PathData, stored: &[f64], relative: bool) -> Vec<f64> {
|
||||
if !relative {
|
||||
return stored.to_vec();
|
||||
}
|
||||
let corners = path_corners(base);
|
||||
let mut out = stored.to_vec();
|
||||
out.resize(base.anchor_count(), 0.0);
|
||||
for c in corners {
|
||||
if let Some(r) = out.get_mut(c.index) {
|
||||
*r = (*r).clamp(0.0, 1.0) * c.max_radius();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn stored absolute radii into fractions of each corner's maximum, or the other way.
|
||||
pub fn convert_radii_relative(live: &mut LiveShape, relative: bool) {
|
||||
if live.radii_relative() == relative {
|
||||
return;
|
||||
}
|
||||
let was_relative = live.radii_relative();
|
||||
let base = match live {
|
||||
LiveShape::Rectangle { w, h, .. } => shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)),
|
||||
LiveShape::Polygon { .. } => live.polygon_outline(),
|
||||
LiveShape::Path { base, .. } => base.clone(),
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
};
|
||||
let corners = path_corners(&base);
|
||||
let radii: &mut [f64] = match live {
|
||||
LiveShape::Rectangle { radii, .. } => radii,
|
||||
LiveShape::Polygon { radii, .. } => radii,
|
||||
LiveShape::Path { radii, .. } => radii,
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
};
|
||||
for c in corners {
|
||||
if let Some(r) = radii.get(c.index) {
|
||||
let max = c.max_radius();
|
||||
radii[c.index] = if relative {
|
||||
if max > 1e-12 && !was_relative { (*r / max).clamp(0.0, 1.0) } else { *r }
|
||||
} else if was_relative {
|
||||
(*r).clamp(0.0, 1.0) * max
|
||||
} else {
|
||||
*r
|
||||
};
|
||||
}
|
||||
}
|
||||
match live {
|
||||
LiveShape::Rectangle { radii_relative: rel, .. }
|
||||
| LiveShape::Polygon { radii_relative: rel, .. }
|
||||
| LiveShape::Path { radii_relative: rel, .. } => {
|
||||
*rel = relative;
|
||||
}
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Live Corners of a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiveCorners<'a> {
|
||||
@@ -26,6 +83,8 @@ pub struct LiveCorners<'a> {
|
||||
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
|
||||
drawn: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// Stored radii are fractions of each corner's maximum, not document lengths.
|
||||
relative: bool,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
}
|
||||
@@ -34,16 +93,21 @@ impl<'a> LiveCorners<'a> {
|
||||
/// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses,
|
||||
/// lines).
|
||||
pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option<Self> {
|
||||
let (base, xf, radii, kinds) = match live {
|
||||
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]),
|
||||
let (base, xf, stored, kinds, relative) = match live {
|
||||
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![], false),
|
||||
Some(l @ LiveShape::Rectangle { .. }) => {
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None };
|
||||
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec())
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } = l.folded() else { return None };
|
||||
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec(), radii_relative)
|
||||
}
|
||||
Some(l @ LiveShape::Polygon { radii, kinds, radii_relative, .. }) => {
|
||||
(Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
|
||||
}
|
||||
Some(LiveShape::Path { base, radii, kinds, radii_relative }) => {
|
||||
(Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
|
||||
}
|
||||
Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None,
|
||||
};
|
||||
let radii = resolve_stored_radii(&base, &stored, relative);
|
||||
let sources = if radii.iter().any(|r| *r > 0.0) {
|
||||
cut_corners(&base, &radii, &kinds).1
|
||||
} else {
|
||||
@@ -58,7 +122,7 @@ impl<'a> LiveCorners<'a> {
|
||||
};
|
||||
let corners = path_corners(&base);
|
||||
let drawn = fitted_radii(&corners, &radii);
|
||||
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
|
||||
Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
@@ -72,7 +136,13 @@ impl<'a> LiveCorners<'a> {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
|
||||
}
|
||||
|
||||
/// The radius corner `index` is set to, in document units (drawn no larger than fits).
|
||||
/// Whether corner radii are stored as fractions of each corner's maximum.
|
||||
pub fn radii_relative(&self) -> bool {
|
||||
self.relative
|
||||
}
|
||||
|
||||
/// The radius corner `index` is stored at: document lengths, or a fraction in 0..1 when
|
||||
/// [`LiveCorners::radii_relative`].
|
||||
pub fn radius(&self, index: usize) -> f64 {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
@@ -119,8 +189,9 @@ impl<'a> LiveCorners<'a> {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// The radius (document units) and kind `corners` share (each `None` when they differ, or
|
||||
/// for no corners).
|
||||
/// The radius and kind `corners` share (each `None` when they differ, or for no corners).
|
||||
/// Radius is what was set ([`LiveCorners::radius`]: document lengths, or a fraction in 0..1
|
||||
/// when [`LiveCorners::radii_relative`]), not the fitted draw size ([`LiveCorners::drawn_radius`]).
|
||||
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
|
||||
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let first = it.next()?;
|
||||
@@ -137,29 +208,31 @@ impl<'a> LiveCorners<'a> {
|
||||
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
|
||||
/// are.
|
||||
pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
fn edit(
|
||||
base: &PathData,
|
||||
relative: bool,
|
||||
radii: &mut [f64],
|
||||
kinds: &mut [CornerKind],
|
||||
corners: &BTreeSet<usize>,
|
||||
radius: Option<f64>,
|
||||
kind: Option<CornerKind>,
|
||||
) {
|
||||
let corners_on_base = path_corners(base);
|
||||
for k in corners {
|
||||
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
|
||||
*slot = r.max(0.0);
|
||||
*slot = if relative {
|
||||
let max = corners_on_base.iter().find(|c| c.index == *k).map(|c| c.max_radius()).unwrap_or(0.0);
|
||||
if max > 1e-12 { (r / max).clamp(0.0, 1.0) } else { 0.0 }
|
||||
} else {
|
||||
r.max(0.0)
|
||||
};
|
||||
}
|
||||
if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) {
|
||||
*slot = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One radius and kind per corner of an outline of `n` anchors while editing; none saved
|
||||
/// when all are sharp or round.
|
||||
fn edit_vec(
|
||||
n: usize,
|
||||
radii: &mut Vec<f64>,
|
||||
kinds: &mut Vec<CornerKind>,
|
||||
corners: &BTreeSet<usize>,
|
||||
radius: Option<f64>,
|
||||
kind: Option<CornerKind>,
|
||||
) {
|
||||
radii.resize(n, 0.0);
|
||||
kinds.resize(n, CornerKind::Round);
|
||||
edit(radii, kinds, corners, radius, kind);
|
||||
fn trim_vec_radii(radii: &mut Vec<f64>, kinds: &mut Vec<CornerKind>) {
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
radii.clear();
|
||||
}
|
||||
@@ -167,18 +240,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corne
|
||||
kinds.clear();
|
||||
}
|
||||
}
|
||||
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }));
|
||||
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![], radii_relative: false }));
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
|
||||
// an uneven scale in its transform (#442).
|
||||
l.fold_scale();
|
||||
let relative = l.radii_relative();
|
||||
match &mut **l {
|
||||
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
|
||||
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
|
||||
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
|
||||
LiveShape::Rectangle { w, h, radii, kinds, .. } => {
|
||||
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
|
||||
edit(&base, relative, radii, kinds, corners, radius, kind);
|
||||
}
|
||||
LiveShape::Polygon { radius: poly_r, sides, xf, radii, kinds, .. } => {
|
||||
let outline = shapes::polygon(Point::ZERO, *poly_r, *sides, 0.0).transformed(*xf);
|
||||
let n = *sides as usize;
|
||||
radii.resize(n, 0.0);
|
||||
kinds.resize(n, CornerKind::Round);
|
||||
edit(&outline, relative, radii, kinds, corners, radius, kind);
|
||||
trim_vec_radii(radii, kinds);
|
||||
}
|
||||
LiveShape::Path { base, radii, kinds, .. } => {
|
||||
let n = base.anchor_count();
|
||||
radii.resize(n, 0.0);
|
||||
kinds.resize(n, CornerKind::Round);
|
||||
edit(base, relative, radii, kinds, corners, radius, kind);
|
||||
trim_vec_radii(radii, kinds);
|
||||
}
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
}
|
||||
match live.as_deref_mut() {
|
||||
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
|
||||
Some(LiveShape::Path { base, radii, kinds, .. }) if radii.is_empty() && kinds.is_empty() => {
|
||||
*path = std::mem::take(base);
|
||||
*live = None;
|
||||
}
|
||||
@@ -202,7 +292,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rectangle_corners_map_to_their_anchors() {
|
||||
let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
let l = LiveShape::Rectangle {
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
radii: [0.0, 10.0, 0.0, 0.0],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: Affine::IDENTITY,
|
||||
};
|
||||
let path = l.to_path();
|
||||
let c = LiveCorners::new(&path, Some(&l)).unwrap();
|
||||
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
|
||||
@@ -229,7 +326,7 @@ mod tests {
|
||||
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
||||
let (mut path, mut live) = (star.clone(), None);
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
|
||||
let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") };
|
||||
let Some(LiveShape::Path { base, radii, kinds, .. }) = live.as_deref() else { panic!("live corners: {live:?}") };
|
||||
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
|
||||
assert_eq!(path.anchor_count(), 12, "two corners cut");
|
||||
let c = LiveCorners::new(&path, live.as_deref()).unwrap();
|
||||
@@ -245,7 +342,8 @@ mod tests {
|
||||
/// A polygon's corners are cut in the document and survive a change of sides when shared.
|
||||
#[test]
|
||||
fn polygon_corners_follow_their_sides() {
|
||||
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
|
||||
let mut l =
|
||||
LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![], radii_relative: false };
|
||||
let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone())));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
|
||||
assert_eq!(path.anchor_count(), 12);
|
||||
@@ -265,6 +363,53 @@ mod tests {
|
||||
assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_radii_scale_with_rectangle_size() {
|
||||
let l = LiveShape::Rectangle {
|
||||
w: 100.0,
|
||||
h: 100.0,
|
||||
radii: [0.25, 0.25, 0.25, 0.25],
|
||||
kinds: Default::default(),
|
||||
radii_relative: true,
|
||||
xf: Affine::IDENTITY,
|
||||
};
|
||||
let path = l.to_path();
|
||||
let c = LiveCorners::new(&path, Some(&l)).unwrap();
|
||||
let r0 = c.drawn_radius(0);
|
||||
let l2 = LiveShape::Rectangle {
|
||||
w: 200.0,
|
||||
h: 200.0,
|
||||
radii: [0.25, 0.25, 0.25, 0.25],
|
||||
kinds: Default::default(),
|
||||
radii_relative: true,
|
||||
xf: Affine::IDENTITY,
|
||||
};
|
||||
let path2 = l2.to_path();
|
||||
let c2 = LiveCorners::new(&path2, Some(&l2)).unwrap();
|
||||
assert!((c2.drawn_radius(0) / r0 - 2.0).abs() < 0.05, "{r0} → {}", c2.drawn_radius(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_between_relative_and_absolute_radii() {
|
||||
let mut l = LiveShape::Rectangle {
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
radii: [10.0, 0.0, 0.0, 0.0],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: Affine::IDENTITY,
|
||||
};
|
||||
let before = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
|
||||
convert_radii_relative(&mut l, true);
|
||||
assert!(l.radii_relative());
|
||||
let mid = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
|
||||
assert!((mid - before).abs() < 0.5);
|
||||
convert_radii_relative(&mut l, false);
|
||||
assert!(!l.radii_relative());
|
||||
let after = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
|
||||
assert!((after - before).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() {
|
||||
let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
|
||||
|
||||
+194
-18
@@ -66,11 +66,16 @@ pub struct HitOptions {
|
||||
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
|
||||
/// on or the guides are hidden.
|
||||
pub skip_guides: bool,
|
||||
/// The selection tools: a clip group's clipping path is hit on its outline, within the
|
||||
/// tolerance, before the art it clips, so that the mask can be reached where the art covers
|
||||
/// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…);
|
||||
/// [`hit_test_visible`] never does it.
|
||||
pub clip_outlines: bool,
|
||||
}
|
||||
|
||||
impl Default for HitOptions {
|
||||
fn default() -> Self {
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +139,11 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
}
|
||||
// The objects on a locked artboard can't be selected, as `hit_test_skipping` passes over them.
|
||||
let locks = doc.locked_artboards();
|
||||
if !locks.is_empty() {
|
||||
tops.retain(|id| !doc.node(*id).is_some_and(|n| locks.holds(n)));
|
||||
}
|
||||
tops
|
||||
}
|
||||
|
||||
@@ -143,8 +153,16 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||||
let Some(children) = layer.children() else { return true };
|
||||
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return true;
|
||||
if layer.clips()
|
||||
&& let Some(clip) = children.first()
|
||||
{
|
||||
// As the hit test does: the clipping mask's outline first, then only inside the mask.
|
||||
if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) {
|
||||
tops.push(clip.id);
|
||||
}
|
||||
if !clip_contains(clip, p) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
@@ -267,14 +285,24 @@ fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
|
||||
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
|
||||
/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art
|
||||
/// ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
let finish = |mut h: Hit| {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
|
||||
h
|
||||
};
|
||||
let locks = doc.locked_artboards();
|
||||
let on_locked = |id: NodeId| {
|
||||
editable_only
|
||||
&& !locks.is_empty()
|
||||
&& doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer)
|
||||
&& doc.node(id).is_some_and(|n| locks.holds(n))
|
||||
};
|
||||
let skip = &|id| skip(id) || on_locked(id);
|
||||
let symbols = doc.symbol_boxes();
|
||||
// Isolation mode: only what is inside the isolated container hits. The art around it is
|
||||
// locked, and clicks pass through it (#833).
|
||||
@@ -283,6 +311,10 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
&& let Some(mut chain) = doc.ancestry(scope)
|
||||
{
|
||||
// Isolated before its artboard was locked: nothing in it hits.
|
||||
if locks.holds_id(scope) {
|
||||
return None;
|
||||
}
|
||||
if isolated.is_container() {
|
||||
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
|
||||
}
|
||||
@@ -363,6 +395,31 @@ fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||||
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
|
||||
}
|
||||
|
||||
/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its
|
||||
/// glyphs, which this crate cannot outline), within the tolerance.
|
||||
fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
let bp = match &clip.kind {
|
||||
NodeKind::Path { path, .. } => path.to_bezpath(),
|
||||
NodeKind::Compound { .. } => clip.stroke_path()?,
|
||||
_ => return None,
|
||||
};
|
||||
stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the
|
||||
/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance:
|
||||
/// it is hit before the art it clips. There Direct Selection takes its segments, and the
|
||||
/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>);
|
||||
/// without this, art drawn over the path's edge is hit first and its segments can't be dragged.
|
||||
/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach).
|
||||
fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option<HitKind> {
|
||||
if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) {
|
||||
return None;
|
||||
}
|
||||
clip_outline(clip, p, opt)
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -373,12 +430,17 @@ fn hit_children(
|
||||
editable_only: bool,
|
||||
) -> Option<Hit> {
|
||||
let children = parent.children()?;
|
||||
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
|
||||
if parent.clips()
|
||||
&& let Some(clip) = children.first()
|
||||
&& !clip_contains(clip, p)
|
||||
{
|
||||
return None;
|
||||
if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) {
|
||||
chain.push(clip.id);
|
||||
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
|
||||
}
|
||||
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
|
||||
if !clip_contains(clip, p) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
@@ -460,10 +522,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
// An image is hit within its frame, and where its own fills and strokes paint
|
||||
// ([`image_paint`]) except in outline mode.
|
||||
NodeKind::Image(_) => {
|
||||
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
|
||||
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
|
||||
// mode.
|
||||
NodeKind::Image(im) => {
|
||||
if opt.path_only {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
|
||||
frame.apply_affine(im.xf);
|
||||
if stroke_contains(&frame, 0.0, opt.tol, p) {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
} else if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return Some(HitKind::Bounds);
|
||||
}
|
||||
if opt.outline {
|
||||
@@ -533,10 +603,11 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
|
||||
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
|
||||
/// path clips to, and then only within that region's bounds.
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
|
||||
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
|
||||
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
|
||||
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
|
||||
/// only within that region's bounds.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
|
||||
@@ -615,8 +686,9 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
let locks = doc.locked_artboards();
|
||||
for (t, r) in tops {
|
||||
if !t.visible || t.locked {
|
||||
if !t.visible || t.locked || locks.holds_id(t.id) {
|
||||
continue;
|
||||
}
|
||||
if leaves {
|
||||
@@ -949,6 +1021,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
|
||||
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
|
||||
/// clipping path and the art.
|
||||
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
|
||||
if layer {
|
||||
d.insert(Some(l), 0, path).unwrap();
|
||||
d.insert(Some(l), 1, picture).unwrap();
|
||||
d.node_mut(l).unwrap().set_clips(true);
|
||||
(d, l, clip, art)
|
||||
} else {
|
||||
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
|
||||
(d, g, clip, art)
|
||||
}
|
||||
}
|
||||
|
||||
const SELECTING: HitOptions =
|
||||
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
|
||||
|
||||
/// For the selection tools a clip group's clipping path is hit on its outline, within the
|
||||
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
|
||||
#[test]
|
||||
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
|
||||
let (d, g, clip, art) = masked_square(false);
|
||||
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
|
||||
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
|
||||
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
|
||||
}
|
||||
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None)), (art, g));
|
||||
// Beyond the tolerance outside it, the clipped art is not there to hit.
|
||||
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
|
||||
}
|
||||
|
||||
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
|
||||
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
|
||||
/// nothing there.
|
||||
#[test]
|
||||
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
|
||||
let (d, _, clip, art) = masked_square(false);
|
||||
let p = Point::new(138.5, 90.0);
|
||||
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
|
||||
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
|
||||
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
|
||||
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
|
||||
// A hidden or locked clipping path is passed over.
|
||||
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
|
||||
let mut d = d.clone();
|
||||
change(d.node_mut(clip).unwrap());
|
||||
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
|
||||
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
|
||||
/// well inside, the art alone (the mask paints nothing there).
|
||||
#[test]
|
||||
fn select_behind_takes_a_layer_masks_outline_first() {
|
||||
let (d, _, clip, art) = masked_square(true);
|
||||
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
|
||||
let p = Point::new(x, 90.0);
|
||||
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
|
||||
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
|
||||
}
|
||||
// Without clip_outlines, as before.
|
||||
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
@@ -1016,6 +1163,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
|
||||
/// a path under it can be picked through it.
|
||||
#[test]
|
||||
fn path_only_picks_paths_through_images() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (path, image) = (d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
|
||||
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
|
||||
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default() };
|
||||
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let path_only = HitOptions { path_only: true, ..Default::default() };
|
||||
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
|
||||
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
|
||||
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
|
||||
assert_eq!(hit(inside, HitOptions::default()), Some(image));
|
||||
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
|
||||
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
|
||||
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
|
||||
}
|
||||
|
||||
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
|
||||
/// frame and type on a path on its path, no longer anywhere in its bounds.
|
||||
#[test]
|
||||
@@ -1243,9 +1412,16 @@ world",
|
||||
let mut d = Document::new(120.0, 120.0);
|
||||
let layer = d.layers[0].id;
|
||||
fill(&mut d, layer, 0, &mut next);
|
||||
// A clipping mask on the layer, or on a sublayer, now and then.
|
||||
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
|
||||
for id in std::iter::once(layer).chain(sublayers) {
|
||||
if next(3) == 0 {
|
||||
d.node_mut(id).unwrap().set_clips(true);
|
||||
}
|
||||
}
|
||||
for _ in 0..12 {
|
||||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+289
-6
@@ -119,8 +119,8 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
|
||||
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
|
||||
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -438,10 +438,112 @@ pub struct Artboard {
|
||||
pub name: String,
|
||||
/// Artboard rectangle in document points.
|
||||
pub rect: Rect,
|
||||
/// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands
|
||||
/// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be
|
||||
/// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays
|
||||
/// selected ([`Document::new_art`]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub locked: bool,
|
||||
#[serde(default)]
|
||||
pub show_center_mark: bool,
|
||||
#[serde(default)]
|
||||
pub show_cross_hairs: bool,
|
||||
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
|
||||
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
|
||||
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
|
||||
/// it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<vectorcraft_color::Color>,
|
||||
}
|
||||
|
||||
/// A document's locked artboards ([`Document::locked_artboards`]).
|
||||
pub struct LockedArtboards<'a> {
|
||||
rects: Vec<Rect>,
|
||||
symbols: SymbolBoxes<'a>,
|
||||
/// The new art, which isn't on them ([`Document::new_art`]).
|
||||
new_art: Arc<std::collections::HashSet<NodeId>>,
|
||||
/// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no
|
||||
/// artboard.
|
||||
pattern_art: std::collections::HashSet<NodeId>,
|
||||
/// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a
|
||||
/// group are each answered with one measurement of the group.
|
||||
held: std::cell::RefCell<std::collections::HashMap<NodeId, bool>>,
|
||||
}
|
||||
|
||||
impl LockedArtboards<'_> {
|
||||
/// No artboard is locked.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rects.is_empty()
|
||||
}
|
||||
/// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its
|
||||
/// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An
|
||||
/// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or
|
||||
/// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or
|
||||
/// new art while it stays selected ([`Document::new_art`]).
|
||||
pub fn holds(&self, n: &Node) -> bool {
|
||||
!self.new_art.contains(&n.id) && self.overlaps(n)
|
||||
}
|
||||
/// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it
|
||||
/// is new art?
|
||||
pub fn overlaps(&self, n: &Node) -> bool {
|
||||
if self.rects.is_empty()
|
||||
|| !n.visible
|
||||
|| n.is_layer()
|
||||
|| matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
|| self.pattern_art.contains(&n.id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(held) = self.held.borrow().get(&n.id) {
|
||||
return *held;
|
||||
}
|
||||
let held =
|
||||
n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0));
|
||||
self.held.borrow_mut().insert(n.id, held);
|
||||
held
|
||||
}
|
||||
/// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id`
|
||||
/// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])?
|
||||
pub fn holds_id(&self, id: NodeId) -> bool {
|
||||
!self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n))
|
||||
}
|
||||
}
|
||||
|
||||
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
|
||||
/// place, so a selection change that sets it doesn't replace the document.
|
||||
#[derive(Default)]
|
||||
pub struct NewArtIds(std::sync::RwLock<Arc<std::collections::HashSet<NodeId>>>);
|
||||
|
||||
impl NewArtIds {
|
||||
/// The objects.
|
||||
pub fn get(&self) -> Arc<std::collections::HashSet<NodeId>> {
|
||||
self.0.read().map(|g| g.clone()).unwrap_or_default()
|
||||
}
|
||||
/// Make `ids` the objects.
|
||||
pub fn set(&self, ids: Arc<std::collections::HashSet<NodeId>>) {
|
||||
if let Ok(mut g) = self.0.write() {
|
||||
*g = ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for NewArtIds {
|
||||
fn clone(&self) -> Self {
|
||||
NewArtIds(std::sync::RwLock::new(self.get()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing state, not document content: two documents are equal whatever new art they name.
|
||||
impl PartialEq for NewArtIds {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NewArtIds {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("NewArtIds").field(&self.get()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
|
||||
@@ -795,6 +897,12 @@ pub struct Document {
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
/// New art on a locked artboard: objects of layers on a locked artboard that an edit made and
|
||||
/// left selected (a group with selected members counts), kept while they stay selected and on
|
||||
/// one. They and their contents can be selected and edited ([`LockedArtboards::holds`]). The
|
||||
/// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved.
|
||||
#[serde(skip)]
|
||||
pub new_art: NewArtIds,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
/// is set, so documents without any pay nothing for them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -882,8 +990,10 @@ impl Document {
|
||||
id: 1,
|
||||
name: "Artboard 1".into(),
|
||||
rect: Rect::new(0.0, 0.0, width, height),
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
}],
|
||||
layers: vec![],
|
||||
swatches: swatches.into(),
|
||||
@@ -925,6 +1035,7 @@ impl Document {
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
puppet: None,
|
||||
new_art: Default::default(),
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
@@ -1358,9 +1469,69 @@ impl Document {
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.layers.iter().map(|l| l.count()).sum()
|
||||
}
|
||||
/// Is the node (and every ancestor) visible and unlocked?
|
||||
/// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard
|
||||
/// ([`Document::on_locked_artboard`])?
|
||||
pub fn is_editable(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
|
||||
self.is_editable_with(&self.locked_artboards(), id)
|
||||
}
|
||||
/// [`Document::is_editable`] with the locked artboards `locks` of this document, measured
|
||||
/// once for many ids.
|
||||
pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id)
|
||||
}
|
||||
/// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once
|
||||
/// for all of them, a group once for all its members.
|
||||
pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ {
|
||||
let locks = self.locked_artboards();
|
||||
move |id| self.is_editable_with(&locks, id)
|
||||
}
|
||||
/// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]).
|
||||
pub fn locked_artboards(&self) -> LockedArtboards<'_> {
|
||||
self.locked_artboards_except(usize::MAX)
|
||||
}
|
||||
/// [`Document::locked_artboards`] leaving out artboard `index`.
|
||||
pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> {
|
||||
// Art flush with an edge from outside can overlap it by a rounding error, as in
|
||||
// `art_bounds_on` (#1295).
|
||||
let rects: Vec<Rect> =
|
||||
self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect();
|
||||
// The objects of pattern editing mode's layer, a sublayer's objects included.
|
||||
fn objects(n: &Node, out: &mut std::collections::HashSet<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
objects(c, out);
|
||||
} else {
|
||||
out.insert(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pattern_art = std::collections::HashSet::new();
|
||||
if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) {
|
||||
objects(layer, &mut pattern_art);
|
||||
}
|
||||
LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() }
|
||||
}
|
||||
/// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none.
|
||||
pub fn on_locked_artboard(&self, id: NodeId) -> bool {
|
||||
self.locked_artboards().holds_id(id)
|
||||
}
|
||||
/// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else
|
||||
/// the group or other object around it right inside a layer. None for a layer or sublayer.
|
||||
pub fn layer_object(&self, id: NodeId) -> Option<&Node> {
|
||||
self.layer_object_at(&self.index_path(id)?)
|
||||
}
|
||||
/// [`Document::layer_object`] for the node at index path `path`.
|
||||
pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> {
|
||||
// Down the path to the first node that isn't a layer.
|
||||
let mut nodes = &self.layers;
|
||||
for &i in path {
|
||||
let n = nodes.get(i)?;
|
||||
if !n.is_layer() {
|
||||
return Some(n.as_ref());
|
||||
}
|
||||
nodes = n.children()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn is_visible(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
|
||||
@@ -1371,9 +1542,16 @@ impl Document {
|
||||
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
|
||||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
|
||||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first),
|
||||
/// except those on a locked artboard.
|
||||
pub fn selectable_art(&self) -> Vec<NodeId> {
|
||||
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
|
||||
let locks = self.locked_artboards();
|
||||
self.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template())
|
||||
.flat_map(|l| l.layer_art(true))
|
||||
.filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n)))
|
||||
.collect()
|
||||
}
|
||||
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
|
||||
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
|
||||
@@ -1446,6 +1624,18 @@ impl Document {
|
||||
}
|
||||
art
|
||||
}
|
||||
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
|
||||
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
|
||||
/// locked art does.
|
||||
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
|
||||
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
|
||||
let mut art = self.art_on_artboard(rect, locked_and_hidden);
|
||||
let locks = self.locked_artboards_except(index);
|
||||
if !locked_and_hidden && !locks.is_empty() {
|
||||
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
|
||||
}
|
||||
art
|
||||
}
|
||||
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
|
||||
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
|
||||
/// artboard is gone.
|
||||
@@ -1932,6 +2122,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one
|
||||
/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with
|
||||
/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one.
|
||||
/// Unlocking the artboard gives the art back.
|
||||
#[test]
|
||||
fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
|
||||
use crate::hit::{HitOptions, hit_test, marquee, objects_at};
|
||||
let (mut d, a, b) = doc_with_rects();
|
||||
let layer = d.layers[0].id;
|
||||
d.artboards.push(Artboard {
|
||||
id: 2,
|
||||
name: "Artboard 2".into(),
|
||||
rect: Rect::new(700.0, 0.0, 900.0, 200.0),
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
});
|
||||
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap();
|
||||
id
|
||||
};
|
||||
let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art());
|
||||
// A group with one member on the artboard and one right of it.
|
||||
let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let members = [(m1, 750.0), (m2, 950.0)]
|
||||
.map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art())));
|
||||
d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap();
|
||||
// Unstroked and flush with the artboard's left edge, from outside.
|
||||
let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default());
|
||||
let opt = HitOptions::default();
|
||||
let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None));
|
||||
let all = Rect::new(-10.0, -10.0, 1000.0, 800.0);
|
||||
assert_eq!(top(&d, 720.0, 20.0), Some(c));
|
||||
d.artboards[1].locked = true;
|
||||
for id in [c, g, m1, m2] {
|
||||
assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard");
|
||||
}
|
||||
for id in [a, b, e, layer] {
|
||||
assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't");
|
||||
}
|
||||
assert_eq!(d.selectable_art(), [a, b, e]);
|
||||
assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c");
|
||||
assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard");
|
||||
let isolated = HitOptions { scope: Some(g), ..opt };
|
||||
assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock");
|
||||
assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty());
|
||||
assert_eq!(marquee(&d, all, None, false), [a, b, e]);
|
||||
assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too");
|
||||
// Deselected in one pass, with the key object and the anchors.
|
||||
let mut sel = Selection::default();
|
||||
sel.set([a, c]);
|
||||
sel.key = Some(c);
|
||||
sel.anchors.insert(c, [(0, 0)].into());
|
||||
sel.deselect_on_locked_artboards(&d);
|
||||
assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true));
|
||||
// A targeted layer stays targeted, with its art off the locked artboard selected.
|
||||
sel.set_target(&d, layer);
|
||||
sel.deselect_on_locked_artboards(&d);
|
||||
assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer)));
|
||||
// Shift-clicking the layer's target circle removes its art off the locked artboard, and
|
||||
// a second Shift-click adds it again.
|
||||
sel.toggle_target(&d, layer);
|
||||
sel.deselect_on_locked_artboards(&d);
|
||||
assert_eq!((sel.objects.clone(), sel.target), (vec![], None));
|
||||
sel.toggle_target(&d, layer);
|
||||
sel.deselect_on_locked_artboards(&d);
|
||||
assert_eq!(sel.objects, [a, b, e]);
|
||||
// A sublayer with art on both artboards: its art off the locked artboard can still be clicked.
|
||||
let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap();
|
||||
for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] {
|
||||
d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art()))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None));
|
||||
assert!(!d.on_locked_artboard(sub));
|
||||
let mut both = Selection::default();
|
||||
both.set([s1, s2]);
|
||||
both.deselect_on_locked_artboards(&d);
|
||||
assert_eq!(both.objects, [s1]);
|
||||
// New art on it is editable and can be clicked while it is new art.
|
||||
d.new_art.set(Arc::new([c].into()));
|
||||
assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art");
|
||||
assert_eq!(top(&d, 720.0, 20.0), Some(c));
|
||||
d.new_art.set(Default::default());
|
||||
d.artboards[1].locked = false;
|
||||
assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]);
|
||||
assert_eq!(top(&d, 955.0, 55.0), Some(g));
|
||||
assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
|
||||
+39
-5
@@ -587,12 +587,18 @@ fn even_steps(n: u32) -> Vec<f64> {
|
||||
(0..=n).map(|i| i as f64 / n as f64).collect()
|
||||
}
|
||||
|
||||
/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from
|
||||
/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid.
|
||||
fn grid_len(rows: u32, cols: u32) -> Option<usize> {
|
||||
(rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?)
|
||||
}
|
||||
|
||||
impl GradientMesh {
|
||||
pub fn idx(&self, r: usize, c: usize) -> usize {
|
||||
r * (self.cols as usize + 1) + c
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.rows >= 1 && self.cols >= 1 && self.points.len() == (self.rows as usize + 1) * (self.cols as usize + 1)
|
||||
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
|
||||
}
|
||||
|
||||
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
|
||||
@@ -1026,7 +1032,8 @@ impl GradientMesh {
|
||||
|
||||
/// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only).
|
||||
pub fn remove_point_lines(&mut self, index: usize) -> bool {
|
||||
if index >= self.points.len() {
|
||||
// A mesh from a file may not have the points its rows and columns need: it is indexed below.
|
||||
if !self.is_valid() || index >= self.points.len() {
|
||||
return false;
|
||||
}
|
||||
let w = self.cols as usize + 1;
|
||||
@@ -1158,7 +1165,7 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
{
|
||||
return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v));
|
||||
}
|
||||
if *rows == 0 || *cols == 0 || points.len() as u64 != (u64::from(*rows) + 1) * (u64::from(*cols) + 1) {
|
||||
if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) {
|
||||
return Box::new(flat);
|
||||
}
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
@@ -1210,8 +1217,7 @@ pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]>
|
||||
/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the
|
||||
/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid.
|
||||
pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option<GradientMesh> {
|
||||
// (Counts come from files: no overflow on 32-bit targets.)
|
||||
if rows == 0 || cols == 0 || points.len() as u64 != (u64::from(rows) + 1) * (u64::from(cols) + 1) {
|
||||
if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) {
|
||||
return None;
|
||||
}
|
||||
let smooth;
|
||||
@@ -1829,6 +1835,20 @@ mod tests {
|
||||
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
/// A file's rows and columns can be anything: a point count too large to compute is no match
|
||||
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
|
||||
#[test]
|
||||
fn a_grid_too_large_to_count_is_malformed() {
|
||||
let m = GradientMesh { rows: u32::MAX, cols: u32::MAX, points: vec![] };
|
||||
assert!(!m.is_valid());
|
||||
assert_eq!((m.lines(), m.outline(), m.quads(8).len()), (PathData::default(), PathData::default(), 0));
|
||||
assert!(envelope_grid(u32::MAX, u32::MAX, &[], &[]).is_none());
|
||||
// A mesh envelope without handles maps through its points: malformed, it leaves art as it is.
|
||||
let kind = EnvelopeKind::Mesh { rows: u32::MAX, cols: u32::MAX, points: vec![], handles: vec![] };
|
||||
let p = Point::new(25.0, 50.0);
|
||||
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_insert_and_remove_lines() {
|
||||
let mut m =
|
||||
@@ -1854,4 +1874,18 @@ mod tests {
|
||||
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
|
||||
/// deletes nothing (it indexed past the points, a panic).
|
||||
#[test]
|
||||
fn mesh_lines_of_a_malformed_mesh_stay() {
|
||||
let point = |i: u32| MeshPoint { p: Point::new(f64::from(i), 0.0), color: Color::BLACK, opacity: 1.0, handles: [Vec2::ZERO; 4] };
|
||||
// An interior row's point and an interior column's point, each with 3 of the 6 points.
|
||||
for (rows, cols, index) in [(2, 1, 2), (1, 2, 1)] {
|
||||
let mut m = GradientMesh { rows, cols, points: (0..3).map(point).collect() };
|
||||
let before = m.clone();
|
||||
assert!(!m.remove_point_lines(index), "{rows}×{cols}, point {index}");
|
||||
assert_eq!(m, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-27
@@ -88,6 +88,9 @@ pub enum LiveShape {
|
||||
/// Corner kinds, in the same order (Live Corners' corner types).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: [CornerKind; 4],
|
||||
/// When true, `radii` are fractions in 0..1 of each corner's largest possible radius.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
radii_relative: bool,
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
|
||||
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
|
||||
/// their scale into `w`/`h`) put more in it.
|
||||
@@ -112,6 +115,8 @@ pub enum LiveShape {
|
||||
/// Corner kinds, in the same order (none: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
radii_relative: bool,
|
||||
},
|
||||
Line {
|
||||
a: Point,
|
||||
@@ -129,10 +134,24 @@ pub enum LiveShape {
|
||||
/// Corner kinds, in the same order (missing: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
radii_relative: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
impl LiveShape {
|
||||
pub fn radii_relative(&self) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => {
|
||||
*radii_relative
|
||||
}
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false,
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
|
||||
@@ -146,13 +165,23 @@ impl LiveShape {
|
||||
/// Regenerate the path.
|
||||
pub fn to_path(&self) -> PathData {
|
||||
match self {
|
||||
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
|
||||
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
|
||||
LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => {
|
||||
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
|
||||
let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative);
|
||||
let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0));
|
||||
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf)
|
||||
}
|
||||
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
|
||||
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
|
||||
LiveShape::Polygon { radii, kinds, radii_relative, .. } => {
|
||||
let outline = self.polygon_outline();
|
||||
let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative);
|
||||
cut_corners(&outline, &radii, kinds).0
|
||||
}
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
|
||||
LiveShape::Path { base, radii, kinds, radii_relative } => {
|
||||
let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative);
|
||||
cut_corners(base, &radii, kinds).0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
|
||||
@@ -253,13 +282,13 @@ impl LiveShape {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Polygon { xf, radii, .. } => {
|
||||
LiveShape::Polygon { xf, radii, radii_relative, .. } => {
|
||||
*xf = a * *xf;
|
||||
scale_radii(radii, a)
|
||||
scale_radii(radii, a, *radii_relative)
|
||||
}
|
||||
LiveShape::Path { base, radii, .. } => {
|
||||
LiveShape::Path { base, radii, radii_relative, .. } => {
|
||||
base.transform(a);
|
||||
scale_radii(radii, a)
|
||||
scale_radii(radii, a, *radii_relative)
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
@@ -274,7 +303,7 @@ impl LiveShape {
|
||||
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
|
||||
/// uneven scale in `xf`: their corners are elliptical until this runs.
|
||||
pub fn fold_scale(&mut self) -> bool {
|
||||
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
|
||||
let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false };
|
||||
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
@@ -284,7 +313,9 @@ impl LiveShape {
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
if !*radii_relative {
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
}
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
@@ -300,12 +331,13 @@ impl LiveShape {
|
||||
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
|
||||
/// keep their size (Scale Corners off). False when nothing changed.
|
||||
pub fn keep_corners(&mut self, k: f64) -> bool {
|
||||
let relative = self.radii_relative();
|
||||
let radii: &mut [f64] = match self {
|
||||
LiveShape::Rectangle { radii, .. } => radii,
|
||||
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
|
||||
};
|
||||
if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
|
||||
if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
radii.iter_mut().for_each(|r| *r /= k);
|
||||
@@ -343,8 +375,8 @@ fn moves_only(a: Affine) -> bool {
|
||||
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
|
||||
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
|
||||
/// uneven scale would otherwise make the cuts elliptical).
|
||||
fn scale_radii(radii: &mut [f64], a: Affine) -> bool {
|
||||
if moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
|
||||
fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool {
|
||||
if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let k = a.determinant().abs().sqrt();
|
||||
@@ -1159,6 +1191,17 @@ impl Node {
|
||||
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
self.geometric(Some(symbols))
|
||||
}
|
||||
/// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a
|
||||
/// hidden bitmap for re-tracing/release; only source-image views measure that bitmap.
|
||||
pub fn editing_children(&self) -> &[Arc<Node>] {
|
||||
let children = self.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let trace_source = self.extras().trace.is_some()
|
||||
&& !self.trace_view().shows_source()
|
||||
&& children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_)));
|
||||
let skip = usize::from(self.extras().shaper.is_some() || trace_source);
|
||||
children.get(skip..).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
|
||||
/// is given.
|
||||
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
@@ -1168,9 +1211,9 @@ impl Node {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
children.first().and_then(|c| c.geometric(symbols))
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self
|
||||
.editing_children()
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
@@ -1223,14 +1266,10 @@ impl Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
|
||||
@@ -2168,14 +2207,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rect_live_shape_roundtrip() {
|
||||
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
|
||||
let l = LiveShape::Rectangle {
|
||||
w: 10.0,
|
||||
h: 20.0,
|
||||
radii: [0.0; 4],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: Affine::translate((5.0, 5.0)),
|
||||
};
|
||||
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
|
||||
assert_eq!(l.label(), "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_kinds_save_only_when_some_corner_isnt_round() {
|
||||
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
let mut l = LiveShape::Rectangle {
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
radii: [0.0, 10.0, 0.0, 0.0],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: Affine::IDENTITY,
|
||||
};
|
||||
// Older files read as round.
|
||||
let json = serde_json::to_value(&l).unwrap();
|
||||
assert!(json.get("kinds").is_none(), "{json}");
|
||||
@@ -2189,9 +2242,9 @@ mod tests {
|
||||
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
|
||||
let p: LiveShape =
|
||||
serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
|
||||
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] });
|
||||
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false });
|
||||
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
|
||||
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] };
|
||||
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false };
|
||||
let json = serde_json::to_value(&path).unwrap();
|
||||
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
|
||||
@@ -2221,7 +2274,7 @@ mod tests {
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
|
||||
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
|
||||
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
|
||||
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false };
|
||||
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
|
||||
p.set_polygon_angle(45.0);
|
||||
assert!(close(p.polygon_angle().unwrap(), 45.0));
|
||||
@@ -2246,7 +2299,7 @@ mod tests {
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf };
|
||||
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(Box::new(live));
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::blend::Spine;
|
||||
use crate::symbols::SymbolBoxes;
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// The center of the nine reference points.
|
||||
pub const CENTER: u8 = 4;
|
||||
@@ -157,6 +157,47 @@ pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What keeps an object from going into an Objects on Path group, as its path or as one of its
|
||||
/// objects ([`held`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Held {
|
||||
/// It is inside a compound path, blend, envelope, repeat or Intertwine group, as part of its art.
|
||||
Live,
|
||||
/// It is the clipping path of a clip group or of a layer with a clipping mask, or inside one.
|
||||
Clipping,
|
||||
}
|
||||
|
||||
/// What keeps the object `id` of `d` where it is ([`Held`]), if anything: in an Objects on Path
|
||||
/// group it would no longer be a part of the compound path, blend, envelope, repeat or Intertwine
|
||||
/// group it is in, or of the clipping path it is or is in. The innermost reason counts. `None` for
|
||||
/// an object that is neither a clipping path nor inside one and is in a layer directly or through
|
||||
/// plain groups and clip groups, and for an id not in `d`.
|
||||
pub fn held(d: &Document, id: NodeId) -> Option<Held> {
|
||||
let path = d.index_path(id)?;
|
||||
let mut chain: Vec<&Node> = Vec::with_capacity(path.len());
|
||||
let mut nodes = &d.layers;
|
||||
for &i in &path {
|
||||
let n: &Node = nodes.get(i)?;
|
||||
chain.push(n);
|
||||
if let Some(children) = n.children() {
|
||||
nodes = children;
|
||||
}
|
||||
}
|
||||
for (depth, n) in chain.iter().enumerate().rev() {
|
||||
let Some(parent) = depth.checked_sub(1).and_then(|up| chain.get(up)) else { break };
|
||||
if matches!(n.kind, NodeKind::Path { clipping: true, .. }) || (parent.clips() && path.get(depth) == Some(&0)) {
|
||||
return Some(Held::Clipping);
|
||||
}
|
||||
if matches!(
|
||||
parent.kind,
|
||||
NodeKind::Compound { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. }
|
||||
) {
|
||||
return Some(Held::Live);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
|
||||
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
|
||||
/// that frame, an object stands square to the path or across it.
|
||||
|
||||
@@ -90,8 +90,8 @@ impl Node {
|
||||
/// ([`Node::geometric_bounds_with`]).
|
||||
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
|
||||
NodeKind::Group { clip: false, .. } => {
|
||||
let b = self.editing_children().iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
|
||||
// The group's own strokes paint around its members (as in `visual_bounds`).
|
||||
let o = if visual { self.appearance.outset() } else { 0.0 };
|
||||
b.map(|b| b.inflate(o, o))
|
||||
|
||||
@@ -467,6 +467,14 @@ impl RepeatKind {
|
||||
RepeatKind::Mirror { .. } => "Mirror Repeat",
|
||||
}
|
||||
}
|
||||
/// How many instances [`repeat_transforms`] makes (with its clamps).
|
||||
fn count(&self) -> usize {
|
||||
match self {
|
||||
RepeatKind::Radial { instances, .. } => (*instances).clamp(1, 1000) as usize,
|
||||
RepeatKind::Grid { rows, cols, .. } => (*rows).clamp(1, 500) as usize * (*cols).clamp(1, 500) as usize,
|
||||
RepeatKind::Mirror { .. } => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -475,6 +483,23 @@ pub struct RepeatSpec {
|
||||
pub source: Vec<Arc<Node>>,
|
||||
}
|
||||
|
||||
/// Most instances a Repeat makes in all, those of the Repeats nested in its source included: as
|
||||
/// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps.
|
||||
const MAX_REPEAT_INSTANCES: usize = 250_000;
|
||||
|
||||
/// Instances the Repeats in `nodes` make in all (0 when there are none).
|
||||
fn nested_instances(nodes: &[Arc<Node>]) -> usize {
|
||||
let mut sum = 0usize;
|
||||
for n in nodes {
|
||||
sum = sum.saturating_add(match (&n.kind, n.children()) {
|
||||
(NodeKind::Repeat(r), _) => r.instances_in_all(),
|
||||
(_, Some(c)) => nested_instances(c),
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// Reflection across the line through `c` with direction angle `deg`.
|
||||
pub fn reflect_about(c: Point, deg: f64) -> Affine {
|
||||
let t = (2.0 * deg).to_radians();
|
||||
@@ -536,7 +561,26 @@ impl RepeatSpec {
|
||||
crate::live::nodes_bounds(&self.source)
|
||||
}
|
||||
pub fn transforms(&self) -> Vec<Affine> {
|
||||
repeat_transforms(&self.kind, self.source_bounds())
|
||||
let mut v = repeat_transforms(&self.kind, self.source_bounds());
|
||||
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
|
||||
// grid of one is 62.5 billion instances): keep the first that fit the budget.
|
||||
v.truncate(self.kept(nested_instances(&self.source)));
|
||||
v
|
||||
}
|
||||
/// How many of its instances this Repeat keeps when each makes `nested` instances of the
|
||||
/// Repeats in its source: as many as fit in [`MAX_REPEAT_INSTANCES`], at least one.
|
||||
fn kept(&self, nested: usize) -> usize {
|
||||
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1)
|
||||
}
|
||||
/// How many instances this Repeat draws ([`Self::transforms`]): as many as its options make,
|
||||
/// fewer when the Repeats in its source would take it past [`MAX_REPEAT_INSTANCES`].
|
||||
pub fn instances(&self) -> usize {
|
||||
self.kept(nested_instances(&self.source))
|
||||
}
|
||||
/// Instances made in all, those of the Repeats nested in the source included.
|
||||
fn instances_in_all(&self) -> usize {
|
||||
let nested = nested_instances(&self.source);
|
||||
self.kept(nested).saturating_mul(nested.max(1))
|
||||
}
|
||||
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
|
||||
pub fn expand(&self) -> Vec<Node> {
|
||||
@@ -838,6 +882,35 @@ mod tests {
|
||||
assert!((angle.rem_euclid(180.0) - 0.0).abs() < 1e-9 || (angle.rem_euclid(180.0) - 180.0).abs() < 1e-9, "{angle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repeats_share_one_instance_budget() {
|
||||
// Instances made in all, counted from the transforms without drawing any.
|
||||
fn made(r: &RepeatSpec) -> usize {
|
||||
let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum();
|
||||
r.transforms().len().saturating_mul(each)
|
||||
}
|
||||
let grid = |source: Arc<Node>| {
|
||||
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
|
||||
if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind {
|
||||
*rows = 500;
|
||||
*cols = 500;
|
||||
}
|
||||
g
|
||||
};
|
||||
let repeat = |r: RepeatSpec| Arc::new(Node::new(NodeId(2), NodeKind::Repeat(r)));
|
||||
// One grid keeps all its instances.
|
||||
let inner = grid(sq(0.0, 0.0, 1.0));
|
||||
assert_eq!(made(&inner), 250_000);
|
||||
// A 500 × 500 grid of it made 62.5 billion.
|
||||
let outer = grid(repeat(inner.clone()));
|
||||
assert!(made(&outer) <= 250_000, "{} instances", made(&outer));
|
||||
assert_eq!(outer.expand().len(), outer.transforms().len());
|
||||
assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000);
|
||||
// Small nests are left as they are.
|
||||
let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0);
|
||||
assert_eq!(made(&small), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -48,6 +49,11 @@ pub struct Selection {
|
||||
/// with `key`; [`Self::prune`] clears it when no object is selected to align to it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_guide: Option<usize>,
|
||||
/// The new art on a locked artboard that this selection keeps editable
|
||||
/// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and
|
||||
/// the engine then sets the document's from it. Editing state, never saved.
|
||||
#[serde(skip)]
|
||||
pub new_art: Arc<HashSet<NodeId>>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
@@ -148,7 +154,11 @@ impl Selection {
|
||||
if self.anchors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc = doc.bounds_of(&self.whole(), visual);
|
||||
vectorcraft_geom::union_opt(doc.bounds_of(&self.whole(), visual), self.anchors_box(doc))
|
||||
}
|
||||
/// The box around the anchors selected with Direct Selection. `None` without any.
|
||||
pub fn anchors_box(&self, doc: &Document) -> Option<vectorcraft_geom::Rect> {
|
||||
let mut acc = None;
|
||||
for (id, set) in &self.anchors {
|
||||
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
|
||||
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
|
||||
@@ -202,16 +212,36 @@ impl Selection {
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
/// selected.
|
||||
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
||||
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
if uneditable.is_empty() {
|
||||
let locks = doc.locked_artboards();
|
||||
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable_with(&locks, *o)).collect();
|
||||
self.deselect_all(&uneditable);
|
||||
}
|
||||
/// Deselect the objects on a locked artboard ([`crate::LockedArtboards::holds_id`]), whatever
|
||||
/// selected them or moved them there (and so the key object when it is one): they act as
|
||||
/// locked art. A targeted layer stays targeted, as it does with locked objects in it.
|
||||
pub fn deselect_on_locked_artboards(&mut self, doc: &Document) {
|
||||
let locks = doc.locked_artboards();
|
||||
if locks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let locked: HashSet<NodeId> = self.objects.iter().copied().filter(|o| locks.holds_id(*o)).collect();
|
||||
let layer = self.target.filter(|t| doc.node(*t).is_some_and(crate::Node::is_layer));
|
||||
self.deselect_all(&locked);
|
||||
if layer.is_some() {
|
||||
self.target = layer;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects `gone` in one pass, with their anchors, segments and key.
|
||||
fn deselect_all(&mut self, gone: &HashSet<NodeId>) {
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.objects.retain(|o| !uneditable.contains(o));
|
||||
self.anchors.retain(|id, _| !uneditable.contains(id));
|
||||
self.segments.retain(|id, _| !uneditable.contains(id));
|
||||
if self.key.is_some_and(|k| uneditable.contains(&k)) {
|
||||
self.objects.retain(|o| !gone.contains(o));
|
||||
self.anchors.retain(|id, _| !gone.contains(id));
|
||||
self.segments.retain(|id, _| !gone.contains(id));
|
||||
if self.key.is_some_and(|k| gone.contains(&k)) {
|
||||
self.key = None;
|
||||
}
|
||||
}
|
||||
@@ -225,12 +255,16 @@ impl Selection {
|
||||
self.target = Some(id);
|
||||
}
|
||||
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
|
||||
/// when it is all selected already. Several objects are then selected, none targeted.
|
||||
/// when it is all selected already. Several objects are then selected, none targeted. Art on a
|
||||
/// locked artboard is neither counted nor added, since
|
||||
/// [`Selection::deselect_on_locked_artboards`] deselects it.
|
||||
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
|
||||
let ids = match doc.node(id) {
|
||||
let mut ids = match doc.node(id) {
|
||||
Some(n) if n.is_layer() => n.layer_art(true),
|
||||
_ => vec![id],
|
||||
};
|
||||
let locks = doc.locked_artboards();
|
||||
ids.retain(|i| !locks.holds_id(*i));
|
||||
if ids.iter().all(|i| self.contains(*i)) {
|
||||
ids.iter().for_each(|i| self.remove(*i));
|
||||
} else {
|
||||
|
||||
@@ -238,6 +238,11 @@ pub struct DocSetup {
|
||||
pub export_text: ExportText,
|
||||
/// Background Contents chosen in New Document.
|
||||
pub background: Background,
|
||||
/// Character panel menu › System Layout: every text object advances by whole points. Off by
|
||||
/// default and for documents from before it, and saved only when on.
|
||||
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
|
||||
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub system_layout: bool,
|
||||
}
|
||||
|
||||
impl Default for DocSetup {
|
||||
@@ -260,6 +265,7 @@ impl Default for DocSetup {
|
||||
small_caps_size: 70.0,
|
||||
export_text: ExportText::Editable,
|
||||
background: Background::Transparent,
|
||||
system_layout: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
|
||||
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
|
||||
/// object, before its children).
|
||||
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(mask) = n.extras().mask.as_deref() {
|
||||
walk_with_masks(&mask.art, f);
|
||||
|
||||
+265
-2
@@ -109,10 +109,22 @@ pub struct CharStyle {
|
||||
/// its proportional heights, `vpal`.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: bool,
|
||||
/// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters
|
||||
/// stand side by side across one em of the column. Horizontal type keeps the mark and does not
|
||||
/// move them. Two or three half-width digits do this without the mark.
|
||||
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
|
||||
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub tate_chu_yoko: bool,
|
||||
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
|
||||
/// these characters are drawn in, whatever digits were typed.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub digits: Digits,
|
||||
/// No Break (Character panel menu): these characters are not broken across lines, including
|
||||
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
|
||||
/// and can overflow it.
|
||||
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
|
||||
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub no_break: bool,
|
||||
}
|
||||
|
||||
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
|
||||
@@ -249,6 +261,9 @@ fn regular() -> String {
|
||||
fn hundred() -> f64 {
|
||||
100.0
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for CharStyle {
|
||||
fn default() -> Self {
|
||||
@@ -283,8 +298,10 @@ impl Default for CharStyle {
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
tate_chu_yoko: false,
|
||||
small_caps: None,
|
||||
digits: Digits::Default,
|
||||
no_break: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,6 +615,35 @@ impl Default for JustificationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of
|
||||
/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair
|
||||
/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter
|
||||
/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font
|
||||
/// draws them, at most an em long.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Kashida {
|
||||
/// No kashidas: justified lines widen their word spaces.
|
||||
#[default]
|
||||
None,
|
||||
Short,
|
||||
Medium,
|
||||
Long,
|
||||
Stylistic,
|
||||
}
|
||||
|
||||
impl Kashida {
|
||||
/// The longest kashida, in ems of the letter that follows it in the text; 0 for None.
|
||||
pub fn max_em(self) -> f64 {
|
||||
match self {
|
||||
Kashida::None => 0.0,
|
||||
Kashida::Short => 0.25,
|
||||
Kashida::Medium => 0.5,
|
||||
Kashida::Long | Kashida::Stylistic => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -645,6 +691,9 @@ pub struct ParaStyle {
|
||||
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
|
||||
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
|
||||
pub roman_hanging: bool,
|
||||
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
|
||||
/// kashidas before they widen their word spaces.
|
||||
pub kashida: Kashida,
|
||||
}
|
||||
|
||||
impl ParaStyle {
|
||||
@@ -706,6 +755,8 @@ struct ParaStyleFile {
|
||||
kinsoku: Kinsoku,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
roman_hanging: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
kashida: Kashida,
|
||||
}
|
||||
|
||||
impl From<ParaStyle> for ParaStyleFile {
|
||||
@@ -734,6 +785,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -756,6 +808,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,6 +835,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -803,6 +857,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1187,6 +1242,10 @@ fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn fifty() -> f64 {
|
||||
50.0
|
||||
}
|
||||
|
||||
impl InlineArt {
|
||||
pub fn new(symbol: impl Into<String>) -> Self {
|
||||
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
|
||||
@@ -1252,6 +1311,68 @@ fn reverse_from_end(path: &mut PathData) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
|
||||
/// vertical type (the Japanese convention), or below (left in vertical type).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyPosition {
|
||||
#[default]
|
||||
Above,
|
||||
Below,
|
||||
}
|
||||
|
||||
/// How ruby text lines up over its base text when the two are different widths.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyAlign {
|
||||
/// Centred over the base text.
|
||||
#[default]
|
||||
Center,
|
||||
/// Starting at the base text's start (left in horizontal type, top in vertical).
|
||||
Start,
|
||||
/// Ending at the base text's end (right in horizontal type, bottom in vertical).
|
||||
End,
|
||||
}
|
||||
|
||||
/// Ruby (furigana, phonetic guide): a reading set over a range of the text.
|
||||
///
|
||||
/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as
|
||||
/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text;
|
||||
/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and
|
||||
/// allowed to overhang neighbouring characters when the reading is longer than its base.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RubySpan {
|
||||
/// Byte offset of the first base character in the plain text.
|
||||
pub start: usize,
|
||||
/// Byte offset just past the last base character.
|
||||
pub end: usize,
|
||||
/// The reading, set in the base characters' font (its size comes from `size_percent`).
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub position: RubyPosition,
|
||||
#[serde(default)]
|
||||
pub align: RubyAlign,
|
||||
/// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual).
|
||||
#[serde(default = "fifty")]
|
||||
pub size_percent: f64,
|
||||
/// Let a reading wider than its base hang over the neighbouring characters instead of being
|
||||
/// squeezed to the base's width.
|
||||
#[serde(default = "yes")]
|
||||
pub overhang: bool,
|
||||
}
|
||||
|
||||
impl RubySpan {
|
||||
/// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging).
|
||||
pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
|
||||
Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true }
|
||||
}
|
||||
/// `size_percent` made finite and usable: 1–100% (untrusted input).
|
||||
pub fn safe_size_percent(&self) -> f64 {
|
||||
if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
///
|
||||
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
|
||||
@@ -1267,6 +1388,10 @@ pub struct TextObject {
|
||||
/// Maps text space (origin = first baseline start for point type) to the document.
|
||||
pub xf: Affine,
|
||||
pub runs: Vec<TextRun>,
|
||||
/// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on
|
||||
/// the object, so the runs (and their styles) stay the text itself.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ruby: Vec<RubySpan>,
|
||||
#[serde(default)]
|
||||
pub para: ParaStyle,
|
||||
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
|
||||
@@ -1291,10 +1416,22 @@ pub struct TextObject {
|
||||
pub wrap: Vec<WrapShape>,
|
||||
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
|
||||
/// sides of letters such as A and W at a line's start or end hang outside the margin there
|
||||
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
|
||||
/// for new type and for documents from before it, and saved only when on.
|
||||
/// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`).
|
||||
/// Off for new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub optical_margin: bool,
|
||||
/// Character panel menu › System Layout, copied from the document: when on, each character
|
||||
/// that takes space advances by a whole number of points. Off by default, and saved only when
|
||||
/// on. The document setting is [`crate::DocSetup::system_layout`].
|
||||
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub system_layout: bool,
|
||||
/// Character panel menu › Fractional Widths: spacing between characters uses fractions of a
|
||||
/// point. On for new type and for documents from before it, and saved only when off. Off, each
|
||||
/// character that takes any space advances by a whole number of points, at least one, so small
|
||||
/// type does not run together. The setting is the whole type object, not a range of characters.
|
||||
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
|
||||
#[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub fractional_widths: bool,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
@@ -1347,12 +1484,43 @@ impl TextObject {
|
||||
Some(style)
|
||||
}
|
||||
|
||||
/// Bakes the object's scale ([`style_scale`]) into character attributes and removes that
|
||||
/// scale from `xf`, so panels show document-point sizes without a separate transform factor.
|
||||
/// Returns `false` when there is nothing to bake (identity scale or an unrepresentable
|
||||
/// transform).
|
||||
pub fn scale_text_attributes(&mut self) -> bool {
|
||||
let Some(scale) = self.style_scale() else { return false };
|
||||
if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let [a, b, c, d, _, _] = self.xf.as_coeffs();
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
if sx <= 1e-12 || sy <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let mut baked = Vec::with_capacity(self.runs.len());
|
||||
for run in &self.runs {
|
||||
let Some(eff) = self.effective_char_style(&run.style) else { return false };
|
||||
baked.push(eff);
|
||||
}
|
||||
for (run, style) in self.runs.iter_mut().zip(baked) {
|
||||
run.style = style;
|
||||
}
|
||||
// Character strokes are drawn in text space: widen them by the vertical scale we remove.
|
||||
self.scale_char_strokes(1.0 / sy);
|
||||
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
|
||||
self.cached_bounds = None;
|
||||
self.cached_baselines.clear();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
kind: TextKind::Point,
|
||||
xf: Affine::translate(origin.to_vec2()),
|
||||
runs: vec![TextRun::new(text, style)],
|
||||
ruby: Vec::new(),
|
||||
para: ParaStyle::default(),
|
||||
paras: Vec::new(),
|
||||
area: AreaOptions::default(),
|
||||
@@ -1361,6 +1529,8 @@ impl TextObject {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
@@ -1368,6 +1538,54 @@ impl TextObject {
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
|
||||
///
|
||||
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
|
||||
/// range past the text or an inverted one would lay out nothing (or panic in a silly place).
|
||||
pub fn normalize_ruby(&mut self) {
|
||||
let len: usize = self.runs.iter().map(|r| r.text.len()).sum();
|
||||
for r in &mut self.ruby {
|
||||
r.start = r.start.min(len);
|
||||
r.end = r.end.min(len);
|
||||
if r.start > r.end {
|
||||
std::mem::swap(&mut r.start, &mut r.end);
|
||||
}
|
||||
r.text = r.text.trim().to_string();
|
||||
}
|
||||
self.ruby.retain(|r| r.start < r.end && !r.text.is_empty());
|
||||
self.ruby.sort_by_key(|r| (r.start, r.end));
|
||||
}
|
||||
/// The ruby span covering byte `byte` of the plain text, if any.
|
||||
pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> {
|
||||
self.ruby.iter().find(|r| r.start <= byte && byte < r.end)
|
||||
}
|
||||
/// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered.
|
||||
pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator<Item = &RubySpan> {
|
||||
let (a, b) = (start.min(end), start.max(end));
|
||||
self.ruby.iter().filter(move |r| r.start < b && a < r.end)
|
||||
}
|
||||
/// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with
|
||||
/// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into
|
||||
/// keep their start and end up where the replaced part ended. Call it after every edit of the
|
||||
/// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word.
|
||||
pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) {
|
||||
let (start, end) = (start.min(end), start.max(end));
|
||||
let delta = inserted as i64 - (end.saturating_sub(start)) as i64;
|
||||
let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize };
|
||||
for r in &mut self.ruby {
|
||||
if r.end <= start {
|
||||
continue;
|
||||
}
|
||||
if r.start >= end {
|
||||
r.start = shift(r.start);
|
||||
r.end = shift(r.end);
|
||||
} else {
|
||||
r.start = r.start.min(start);
|
||||
r.end = shift(r.end).max(r.start);
|
||||
}
|
||||
}
|
||||
self.normalize_ruby();
|
||||
}
|
||||
/// Number of paragraphs (`\n`-separated; empty text has one).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
|
||||
@@ -1658,12 +1876,57 @@ impl crate::Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches
|
||||
/// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art
|
||||
/// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry
|
||||
/// ranges from outside: a range past the text, an inverted one or an empty reading would lay
|
||||
/// out nothing. It recurses through the art: call it on a document no deeper than
|
||||
/// [`crate::depth::MAX_DEPTH`].
|
||||
pub fn fix_ruby(&mut self) {
|
||||
let mut fix = |n: &mut crate::Node| {
|
||||
if let crate::NodeKind::Text(t) = &mut n.kind {
|
||||
t.normalize_ruby();
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
|
||||
#[test]
|
||||
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
|
||||
let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default());
|
||||
t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }];
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
assert!(json.contains("かんじ"));
|
||||
let back: TextObject = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.ruby, t.ruby);
|
||||
assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang);
|
||||
// Old files have no `ruby` key: none.
|
||||
let mut old: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
old.as_object_mut().unwrap().remove("ruby");
|
||||
assert!(serde_json::from_value::<TextObject>(old).unwrap().ruby.is_empty());
|
||||
// A damaged file's range past the text and its empty reading are clamped away.
|
||||
let mut damaged = t.clone();
|
||||
damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")];
|
||||
damaged.normalize_ruby();
|
||||
assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6));
|
||||
}
|
||||
|
||||
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
|
||||
/// are saved only when set.
|
||||
#[test]
|
||||
|
||||
@@ -89,4 +89,53 @@ mod tests {
|
||||
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
|
||||
assert_eq!(g.trace_view(), TraceView::Result);
|
||||
}
|
||||
#[test]
|
||||
fn live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle() {
|
||||
use crate::{Appearance, Document, ImageObject, NodeId, NodeKind};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath};
|
||||
let mut source = Node::new(
|
||||
NodeId(2),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "synthetic".into(),
|
||||
width: 200,
|
||||
height: 200,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
source.visible = false;
|
||||
let art = Node::path(
|
||||
NodeId(3),
|
||||
PathData::single(SubPath::polyline(
|
||||
&[Point::new(20.0, 30.0), Point::new(80.0, 30.0), Point::new(80.0, 90.0), Point::new(20.0, 90.0)],
|
||||
true,
|
||||
)),
|
||||
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
|
||||
);
|
||||
let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art.clone())]);
|
||||
group.extras_mut().trace = Some(Box::new(serde_json::json!({})));
|
||||
let doc = Document::new(300.0, 300.0);
|
||||
for view in TraceView::ALL {
|
||||
group.extras_mut().trace = Some(Box::new(serde_json::json!({"view":view.id()})));
|
||||
let want = if view.shows_source() { Rect::new(0.0, 0.0, 200.0, 200.0) } else { Rect::new(20.0, 30.0, 80.0, 90.0) };
|
||||
assert_eq!(group.geometric_bounds(), Some(want));
|
||||
assert_eq!(group.visual_bounds(), Some(want));
|
||||
if !view.shows_source() {
|
||||
for angle in [0.0_f64, 30.0, 90.0] {
|
||||
let xf = Affine::rotate(angle.to_radians());
|
||||
for visual in [false, true] {
|
||||
assert_eq!(group.bounds_in(&doc.symbol_boxes(), xf, visual), art.bounds_in(&doc.symbol_boxes(), xf, visual));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
group.extras_mut().trace = None;
|
||||
assert_eq!(
|
||||
group.geometric_bounds(),
|
||||
Some(Rect::new(0.0, 0.0, 200.0, 200.0)),
|
||||
"ordinary hidden group members retain their existing bounds policy"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,12 @@ pub fn to_object_name(id: &str) -> String {
|
||||
/// since a bare `_` would read back as a space.
|
||||
pub fn from_object_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||||
let last = name.chars().count().saturating_sub(1);
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
if c == ' ' {
|
||||
if c == ' ' && i == last {
|
||||
// A trailing bare `_` could read back as the end of a uniqueness suffix.
|
||||
out.push_str("_x20_");
|
||||
} else if c == ' ' {
|
||||
out.push('_');
|
||||
} else if c == '_' {
|
||||
out.push_str("_x5F_");
|
||||
@@ -115,6 +119,10 @@ mod tests {
|
||||
let id = from_object_name(name);
|
||||
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
|
||||
}
|
||||
let name = "Scan 12345678901234567890 ";
|
||||
let id = from_object_name(name);
|
||||
assert!(id.ends_with("_x20_"), "{id:?}");
|
||||
assert_eq!(to_object_name(&id), name);
|
||||
assert_eq!(from_object_name("-abc"), "_x2D_abc");
|
||||
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
|
||||
assert_eq!(from_object_name("²x"), "_xB2_x");
|
||||
|
||||
@@ -151,17 +151,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
bake_leaf(d, n)
|
||||
}
|
||||
|
||||
/// `m`, art evaluated from an object, with its children baked and given ids of their own (the
|
||||
/// pieces copied from the source keep the source's ids until then).
|
||||
/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the
|
||||
/// pieces copied from the source keep the source's ids until then). The pieces are baked with the
|
||||
/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id.
|
||||
fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
|
||||
let mut seen = std::collections::HashSet::from([m.id]);
|
||||
let children = m.children().cloned().unwrap_or_default();
|
||||
let children = children
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut c = Arc::unwrap_or_clone(c);
|
||||
let c = Arc::unwrap_or_clone(c);
|
||||
let mut c = bake_node(d, &c).unwrap_or(c);
|
||||
fresh_ids(d, &mut c, &mut seen);
|
||||
Arc::new(bake_node(d, &c).unwrap_or(c))
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
if let Some(ch) = m.children_mut() {
|
||||
|
||||
+125
-6
@@ -13,12 +13,14 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops::{self as po, PathfinderOp};
|
||||
|
||||
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
|
||||
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [
|
||||
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
|
||||
("pathfinder.add", "Add", PathfinderOp::Unite),
|
||||
("pathfinder.intersect", "Intersect", PathfinderOp::Intersect),
|
||||
("pathfinder.exclude", "Exclude", PathfinderOp::Exclude),
|
||||
@@ -29,6 +31,8 @@ pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [
|
||||
("pathfinder.merge", "Merge", PathfinderOp::Merge),
|
||||
("pathfinder.crop", "Crop", PathfinderOp::Crop),
|
||||
("pathfinder.outline", "Outline", PathfinderOp::Outline),
|
||||
("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix),
|
||||
("pathfinder.softMix", "Soft Mix", PathfinderOp::SoftMix),
|
||||
];
|
||||
|
||||
/// Is `id` an Effect → Pathfinder effect?
|
||||
@@ -138,15 +142,88 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
|
||||
if shapes.is_empty() {
|
||||
return Some(vec![]);
|
||||
}
|
||||
stack = po::pathfinder(op, &shapes)
|
||||
.into_iter()
|
||||
.filter(|r| !r.path.is_empty())
|
||||
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
|
||||
.collect();
|
||||
stack = if op == PathfinderOp::SoftMix {
|
||||
let rate = mixing_rate(&e.params);
|
||||
po::regions(&shapes)
|
||||
.into_iter()
|
||||
.filter(|r| !r.path.is_empty())
|
||||
.map(|r| Arc::new(soft_mix_face(r.path, &r.sources, rate, &shapes, &sources)))
|
||||
.collect()
|
||||
} else if op == PathfinderOp::HardMix {
|
||||
po::regions(&shapes)
|
||||
.into_iter()
|
||||
.filter(|r| !r.path.is_empty())
|
||||
.map(|r| Arc::new(hard_mix_face(r.path, &r.sources, &shapes, &sources)))
|
||||
.collect()
|
||||
} else {
|
||||
po::pathfinder(op, &shapes)
|
||||
.into_iter()
|
||||
.filter(|r| !r.path.is_empty())
|
||||
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
|
||||
.collect()
|
||||
};
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
fn mixing_rate(params: &Value) -> f64 {
|
||||
params.get("mixingRate").and_then(|v| v.as_f64()).filter(|r| r.is_finite() && *r > 0.0 && *r <= 100.0).map(|r| r / 100.0).unwrap_or(0.5)
|
||||
}
|
||||
|
||||
fn fill_cmyk(src: &Node) -> Option<[f32; 4]> {
|
||||
let paint = src.appearance.fill_paint();
|
||||
let color = paint.color()?;
|
||||
let tinted = match paint {
|
||||
Paint::Solid { tint, .. } => color.tinted(tint),
|
||||
_ => color,
|
||||
};
|
||||
Some(tinted.to_cmyk())
|
||||
}
|
||||
|
||||
/// One face of a Soft Mix: CMYK blended bottom to top at `rate`; no stroke.
|
||||
fn soft_mix_face(path: PathData, covering: &[usize], rate: f64, shapes: &[po::Shape], sources: &[Node]) -> Node {
|
||||
let rate = rate as f32;
|
||||
let mut cmyk: Option<[f32; 4]> = None;
|
||||
for &si in covering {
|
||||
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
|
||||
cmyk = Some(match cmyk {
|
||||
None => ch,
|
||||
Some(acc) => {
|
||||
let t = rate;
|
||||
let u = 1.0 - t;
|
||||
[acc[0] * u + ch[0] * t, acc[1] * u + ch[1] * t, acc[2] * u + ch[2] * t, acc[3] * u + ch[3] * t]
|
||||
}
|
||||
});
|
||||
}
|
||||
let fill = cmyk.map(|c| Paint::solid(Color::cmyk(c[0], c[1], c[2], c[3]))).unwrap_or(Paint::None);
|
||||
soft_mix_styled(path, covering, shapes, sources, fill)
|
||||
}
|
||||
|
||||
/// One face of a Hard Mix: the max of each CMYK channel over every covering shape's fill; no stroke.
|
||||
fn hard_mix_face(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node]) -> Node {
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
let mut painted = false;
|
||||
for &si in covering {
|
||||
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
|
||||
for (out, v) in cmyk.iter_mut().zip(ch) {
|
||||
*out = out.max(v);
|
||||
}
|
||||
painted = true;
|
||||
}
|
||||
let fill = if painted { Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])) } else { Paint::None };
|
||||
soft_mix_styled(path, covering, shapes, sources, fill)
|
||||
}
|
||||
|
||||
fn soft_mix_styled(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node], fill: Paint) -> Node {
|
||||
let top = covering.last().and_then(|&si| sources.get(shapes[si].key as usize));
|
||||
let mut n = Node::path(top.map_or(NodeId(0), |s| s.id), path, Appearance::basic(fill, Paint::None, 0.0));
|
||||
if let Some(src) = top {
|
||||
n.opacity = src.opacity;
|
||||
n.blend = src.blend;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
|
||||
}
|
||||
@@ -320,6 +397,48 @@ mod tests {
|
||||
po::area(n.path_data().unwrap(), FillRule::NonZero).abs()
|
||||
}
|
||||
|
||||
fn overlap_cmyk(g: &Node) -> [f32; 4] {
|
||||
let faces = pathfinder_children(g, None).unwrap();
|
||||
assert_eq!(faces.len(), 3);
|
||||
let overlap = faces
|
||||
.iter()
|
||||
.find(|n| {
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9
|
||||
})
|
||||
.expect("overlap face");
|
||||
assert!(overlap.appearance.stroke_paint().is_none());
|
||||
overlap.appearance.fill_paint().color().unwrap().to_cmyk()
|
||||
}
|
||||
|
||||
fn two_overlapping_cmyk_squares() -> Node {
|
||||
let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0);
|
||||
let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0);
|
||||
Node::group(
|
||||
NodeId(10),
|
||||
vec![
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)),
|
||||
Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() {
|
||||
let mut g = two_overlapping_cmyk_squares();
|
||||
g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true });
|
||||
let [c, m, y, k] = overlap_cmyk(&g);
|
||||
assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
|
||||
let mut g = two_overlapping_cmyk_squares();
|
||||
g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true });
|
||||
let [c, m, y, k] = overlap_cmyk(&g);
|
||||
assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_modes_follow_the_pathfinder_panel() {
|
||||
let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap();
|
||||
|
||||
+100
-3
@@ -20,6 +20,8 @@
|
||||
//! art painting its members (its raster effects then apply to the composite).
|
||||
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
|
||||
//! PDF writers.
|
||||
//! - [`preview`] measures objects as they are drawn, with their geometry effects (Use Preview
|
||||
//! Bounds).
|
||||
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
|
||||
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
|
||||
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
|
||||
@@ -38,9 +40,11 @@ mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod lighting;
|
||||
mod lighting_presets;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
pub mod preview;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
@@ -69,6 +73,7 @@ pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
|
||||
pathfinder_children,
|
||||
};
|
||||
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
@@ -77,7 +82,7 @@ pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
view_options,
|
||||
validate_three_d, view_options,
|
||||
};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
@@ -143,6 +148,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
|
||||
"artistic.paletteKnife" => always(&["strokeSize"]),
|
||||
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
|
||||
"brushStrokes.spatter" => always(&["sprayRadius"]),
|
||||
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
|
||||
@@ -156,6 +162,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"sketch.graphicPen" => always(&["strokeLength"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
"sketch.notePaper" => always(&["relief"]),
|
||||
"sketch.tornEdges" => always(&["smoothness"]),
|
||||
"sketch.waterPaper" => always(&["fiberLength"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
@@ -343,6 +351,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"artistic.coloredPencil",
|
||||
"Colored Pencil…",
|
||||
ARTISTIC,
|
||||
"{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.cutout",
|
||||
"Cutout…",
|
||||
@@ -350,6 +365,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.dryBrush",
|
||||
"Dry Brush…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.filmGrain",
|
||||
"Film Grain…",
|
||||
@@ -357,6 +379,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.fresco",
|
||||
"Fresco…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.neonGlow",
|
||||
"Neon Glow…",
|
||||
@@ -364,6 +393,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
|
||||
),
|
||||
r(
|
||||
"artistic.paintDaubs",
|
||||
"Paint Daubs…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}),
|
||||
),
|
||||
r(
|
||||
"artistic.paletteKnife",
|
||||
"Palette Knife…",
|
||||
ARTISTIC,
|
||||
"{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.plasticWrap",
|
||||
"Plastic Wrap…",
|
||||
@@ -378,6 +421,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.roughPastels",
|
||||
"Rough Pastels…",
|
||||
ARTISTIC,
|
||||
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
|
||||
json!({
|
||||
"strokeLength": 6.0,
|
||||
"strokeDetail": 10.0,
|
||||
"texture": "canvas",
|
||||
"scaling": 100.0,
|
||||
"relief": 4.0,
|
||||
"lightDirection": "top",
|
||||
"invert": false
|
||||
}),
|
||||
),
|
||||
r(
|
||||
"artistic.smudgeStick",
|
||||
"Smudge Stick…",
|
||||
@@ -392,6 +450,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.underpainting",
|
||||
"Underpainting…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.",
|
||||
json!({
|
||||
"brushSize": 6.0,
|
||||
"textureCoverage": 16.0,
|
||||
"texture": "canvas",
|
||||
"scaling": 100.0,
|
||||
"relief": 4.0,
|
||||
"lightDirection": "top",
|
||||
"invert": false
|
||||
}),
|
||||
),
|
||||
r(
|
||||
"artistic.watercolor",
|
||||
"Watercolor…",
|
||||
@@ -574,6 +647,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"lightDarkBalance": 25.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.tornEdges",
|
||||
"Torn Edges…",
|
||||
SKETCH,
|
||||
"{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.waterPaper",
|
||||
"Water Paper…",
|
||||
SKETCH,
|
||||
"{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.",
|
||||
json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -648,7 +735,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"texture.texturizer",
|
||||
"Texturizer…",
|
||||
TEXTURE,
|
||||
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
|
||||
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
|
||||
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
|
||||
),
|
||||
r(
|
||||
@@ -731,7 +818,17 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
|
||||
}
|
||||
for (id, label, _) in PATHFINDER_EFFECTS {
|
||||
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
|
||||
if id == "pathfinder.softMix" {
|
||||
v.push(g(
|
||||
id,
|
||||
label,
|
||||
PATHFINDER,
|
||||
"{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)",
|
||||
json!({"mixingRate": 50.0}),
|
||||
));
|
||||
} else {
|
||||
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
|
||||
}
|
||||
}
|
||||
v.push(g(
|
||||
CROP_MARKS,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Original lighting rigs for the publicly documented 3D preset names.
|
||||
//! Adobe publishes the names, not numeric settings. These values are our own; Standard
|
||||
//! retains the existing default. A preset replaces the light rig and ambient only.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_three_d::{DEFAULT_LIGHT, Light};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LightingPreset {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
rotation: f64,
|
||||
height: f64,
|
||||
softness: f64,
|
||||
ambient: f64,
|
||||
}
|
||||
|
||||
pub const LIGHTING_PRESETS: [LightingPreset; 4] = [
|
||||
LightingPreset { id: "standard", label: "Standard", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 0.0, ambient: 25.0 },
|
||||
LightingPreset { id: "diffuse", label: "Diffuse", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 100.0, ambient: 35.0 },
|
||||
LightingPreset { id: "topLeft", label: "Top Left", rotation: 135.0, height: 45.0, softness: 0.0, ambient: 25.0 },
|
||||
LightingPreset { id: "right", label: "Right", rotation: 0.0, height: 30.0, softness: 0.0, ambient: 25.0 },
|
||||
];
|
||||
|
||||
impl LightingPreset {
|
||||
/// The same patch is used by commands and the dialog's preview transaction. Null
|
||||
/// `lights` replaces an explicit multi-light rig with this single white light.
|
||||
pub fn params(self) -> Value {
|
||||
json!({
|
||||
"lights": null, "lightColor": "#ffffff", "lightIntensity": 80.0,
|
||||
"lightRotation": self.rotation, "lightHeight": self.height,
|
||||
"lightSoftness": self.softness, "lightBehind": false, "ambient": self.ambient,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(self, light: Light, ambient: f64) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
!light.behind
|
||||
&& light.color.iter().all(|c| close(*c, 1.0))
|
||||
&& close(light.intensity, 0.8)
|
||||
&& close(light.rotation, self.rotation)
|
||||
&& close(light.height, self.height)
|
||||
&& close(light.softness, self.softness / 100.0)
|
||||
&& close(ambient, self.ambient / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lighting_preset_params(id: &str) -> Option<Value> {
|
||||
LIGHTING_PRESETS.iter().find(|p| p.id == id).map(|p| p.params())
|
||||
}
|
||||
|
||||
/// Identify the effective rig, including an equivalent explicit single light. Names
|
||||
/// are UI state derived from settings, so edits and older files cannot leave stale names.
|
||||
pub fn matching_lighting_preset(params: &Value) -> Option<usize> {
|
||||
let upgraded = crate::upgrade_params(crate::REVOLVE, params);
|
||||
let o = crate::revolve_options(&upgraded);
|
||||
let light = if let Some(lights) = &o.lights {
|
||||
let mut entries = lights.iter();
|
||||
let light = *entries.next()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
light
|
||||
} else {
|
||||
Light {
|
||||
rotation: o.light_rotation,
|
||||
height: o.light_height,
|
||||
behind: o.light_behind,
|
||||
color: o.light_color,
|
||||
intensity: o.light_intensity,
|
||||
softness: o.light_softness,
|
||||
}
|
||||
};
|
||||
LIGHTING_PRESETS.iter().position(|preset| preset.matches(light, o.ambient))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lighting_presets_are_valid_unique_and_standard_preserves_the_default() {
|
||||
for (i, preset) in LIGHTING_PRESETS.iter().enumerate() {
|
||||
let patch = preset.params();
|
||||
crate::lighting::validate(&patch).unwrap();
|
||||
assert_eq!(lighting_preset_params(preset.id), Some(patch.clone()));
|
||||
assert_eq!(matching_lighting_preset(&patch), Some(i));
|
||||
assert!(!patch.as_object().unwrap().contains_key("shade"));
|
||||
assert!(!patch.as_object().unwrap().contains_key("rotationX"));
|
||||
}
|
||||
assert_eq!(matching_lighting_preset(&json!({})), Some(0));
|
||||
assert!(lighting_preset_params("unknown").is_none());
|
||||
assert_eq!(crate::revolve_options(&LIGHTING_PRESETS[0].params()).light_direction(), crate::revolve_options(&json!({})).light_direction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighting_preset_matching_uses_effective_lights_and_detects_custom_edits() {
|
||||
let mut p = LIGHTING_PRESETS[3].params();
|
||||
p["lightColor"] = json!("#ff0000");
|
||||
p["lights"] = json!([{"rotation":0,"height":30}]);
|
||||
assert_eq!(matching_lighting_preset(&p), Some(3), "explicit light overrides stale legacy color");
|
||||
p["lights"][0]["color"] = json!("#4080ff");
|
||||
assert_eq!(matching_lighting_preset(&p), None);
|
||||
p["lights"] = json!([{}, {}]);
|
||||
assert_eq!(matching_lighting_preset(&p), None);
|
||||
for (key, value) in [("ambient", json!(26)), ("lightIntensity", json!(79)), ("lightBehind", json!(true))] {
|
||||
let mut p = LIGHTING_PRESETS[0].params();
|
||||
p[key] = value;
|
||||
assert_eq!(matching_lighting_preset(&p), None, "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,78 @@
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from
|
||||
//! Effect › Artistic (Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster
|
||||
//! Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor). Original filters built from
|
||||
//! public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
|
||||
//! These are visual approximations, not reproductions of a proprietary algorithm.
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use super::brushstrokes::{LEFT, RIGHT, Smear, TONE_SOFTNESS, mix, streak, stroke_reach, unit, unpremultiply};
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Around, Place, Soft, cell, point_in, rand, value_noise};
|
||||
use super::texture::{self, Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
const SALT_DRY: u64 = 264;
|
||||
const MARGIN: f64 = 1.0;
|
||||
const SALT_PLASTIC: u64 = 512;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
const SALT_PASTEL: u64 = 200;
|
||||
|
||||
const SALT_FILM: u64 = 256;
|
||||
const SALT_DAUB: u64 = 272;
|
||||
|
||||
/// Paint Daubs' brush types, in the Brush Type menu's order: (label, parameter value).
|
||||
/// Illustrator's list: <https://helpx.adobe.com/illustrator/using/filter-effects.html#paint_daubs>.
|
||||
pub const PAINT_DAUB_BRUSH_TYPES: [(&str, &str); 6] = [
|
||||
("Simple", "simple"),
|
||||
("Light Rough", "lightRough"),
|
||||
("Dark Rough", "darkRough"),
|
||||
("Wide Sharp", "wideSharp"),
|
||||
("Wide Blurry", "wideBlurry"),
|
||||
("Sparkle", "sparkle"),
|
||||
];
|
||||
|
||||
/// A Paint Daubs brush ([`PAINT_DAUB_BRUSH_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PaintDaubBrush {
|
||||
Simple,
|
||||
LightRough,
|
||||
DarkRough,
|
||||
WideSharp,
|
||||
WideBlurry,
|
||||
Sparkle,
|
||||
}
|
||||
|
||||
impl PaintDaubBrush {
|
||||
const ALL: [PaintDaubBrush; 6] = [
|
||||
PaintDaubBrush::Simple,
|
||||
PaintDaubBrush::LightRough,
|
||||
PaintDaubBrush::DarkRough,
|
||||
PaintDaubBrush::WideSharp,
|
||||
PaintDaubBrush::WideBlurry,
|
||||
PaintDaubBrush::Sparkle,
|
||||
];
|
||||
|
||||
/// The brush of Brush Type `value` (a [`PAINT_DAUB_BRUSH_TYPES`] value, any case); Simple otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
if value.eq_ignore_ascii_case("wideRough") {
|
||||
return PaintDaubBrush::WideSharp;
|
||||
}
|
||||
if value.eq_ignore_ascii_case("hue") {
|
||||
return PaintDaubBrush::WideBlurry;
|
||||
}
|
||||
pick(&PAINT_DAUB_BRUSH_TYPES, &Self::ALL, value, PaintDaubBrush::Simple)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) a dab reaches past its centre at `size` (1..50).
|
||||
pub(super) fn paint_daub_radius(size: f64) -> f64 {
|
||||
size.clamp(1.0, 50.0) * 0.48
|
||||
}
|
||||
const SALT_PENCIL: u64 = 304;
|
||||
const SALT_FRESCO: u64 = 320;
|
||||
|
||||
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
|
||||
/// The cache changes only cost, never the field, and needs constant storage per raster.
|
||||
@@ -161,6 +221,55 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
|
||||
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
|
||||
}
|
||||
|
||||
/// How far (document units) Colored Pencil reaches at pencil width `width`: soften at
|
||||
/// `0.12 * width` and the Sobel neighbourhood around it.
|
||||
pub(super) fn colored_pencil_reach(width: f64) -> f64 {
|
||||
3.0 * 0.12 * width.clamp(1.0, 24.0) + 1.0
|
||||
}
|
||||
|
||||
/// Colored Pencil: the object redrawn with coloured pencil strokes along local edges, keeping its
|
||||
/// hues. `width` (1..24) sets the stroke spacing in document units; `pressure` (0..15) darkens
|
||||
/// along the strokes; `paper` (0..50) lifts lighter areas toward the paper. Coverage is kept.
|
||||
pub(super) fn colored_pencil(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, paper: f64) {
|
||||
if !([width, pressure, paper].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let width = width.clamp(1.0, 24.0);
|
||||
let pressure = (pressure / 15.0).clamp(0.0, 1.0) as f32;
|
||||
let paper_lift = (paper / 50.0).clamp(0.0, 1.0) as f32;
|
||||
if pressure == 0.0 && paper_lift == 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let (mut colour, alpha) = planes(px);
|
||||
soften(&mut colour, &alpha, w, h, (0.12 * width) / space.px);
|
||||
let tones: Vec<f32> = (0..px.len()).map(|k| luma([colour[0][k], colour[1][k], colour[2][k]])).collect();
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
for (e, s) in edges.iter_mut().zip(sobel(&tones, w, h)) {
|
||||
*e = (s / 4.0).min(1.0);
|
||||
}
|
||||
let spacing = (0.35 * width).max(1.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let k = y * w + x;
|
||||
let c = [colour[0][k], colour[1][k], colour[2][k]];
|
||||
let gx = tone_at(&tones, w, h, x as isize + 1, y as isize) - tone_at(&tones, w, h, x as isize - 1, y as isize);
|
||||
let gy = tone_at(&tones, w, h, x as isize, y as isize + 1) - tone_at(&tones, w, h, x as isize, y as isize - 1);
|
||||
let angle = gy.atan2(gx) + std::f32::consts::FRAC_PI_2;
|
||||
let (dx, dy) = (angle.cos() as f64, angle.sin() as f64);
|
||||
let q = place.at(x, y);
|
||||
let along = q.x * dx + q.y * dy;
|
||||
let grain = value_noise(SALT_PENCIL, along / spacing, (q.x * 0.37 + q.y * 0.11) / spacing);
|
||||
let stroke = (0.5 + 0.5 * (along / spacing * TAU + grain * 3.0).sin()) as f32;
|
||||
let ink = pressure * (0.3 + 0.55 * stroke) * (0.55 + 0.45 * edges[k]);
|
||||
let lift = paper_lift * (1.0 - luma(c)) * 0.35;
|
||||
let c = c.map(|v| (v * (1.0 - ink * 0.45) + lift * (1.0 - v)).clamp(0.0, 1.0));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
|
||||
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
|
||||
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
|
||||
@@ -241,6 +350,136 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint Daubs: thick round dabs on a grid `size` (1..50) points apart, each dab painted in the
|
||||
/// local colour around its centre, with edges as sharp as `sharpness` (0..40) and a profile from
|
||||
/// `kind`. Coverage is kept.
|
||||
pub(super) fn paint_daubs(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, sharpness: f64, kind: PaintDaubBrush) {
|
||||
if !([size, sharpness].iter().all(|v| v.is_finite()) && size > 0.0) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let spacing = size.clamp(1.0, 50.0);
|
||||
let brush_r = paint_daub_radius(size);
|
||||
let edge = 1.0 + 2.5 * (sharpness.clamp(0.0, 40.0) / 40.0);
|
||||
let soft = Soft::new(px, w, (spacing * 0.15) / place.px);
|
||||
let dab = |i: i64, j: i64| {
|
||||
let at = point_in(SALT_DAUB, i, j);
|
||||
let center = Point::new(at.x * spacing, at.y * spacing);
|
||||
(center, soft.at(place.index(center)))
|
||||
};
|
||||
let cover = |q: Point, center: Point, colour: [f32; 4]| -> f32 {
|
||||
if colour[3] <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (dx, dy) = (q.x - center.x, q.y - center.y);
|
||||
let (dx, dy) = match kind {
|
||||
PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry => (dx * 0.65, dy * 1.35),
|
||||
_ => (dx, dy),
|
||||
};
|
||||
let t = (dx.hypot(dy) / brush_r).clamp(0.0, 1.0);
|
||||
let edge_k = match kind {
|
||||
PaintDaubBrush::WideSharp => edge * 1.35,
|
||||
PaintDaubBrush::WideBlurry => edge * 0.55,
|
||||
_ => edge,
|
||||
};
|
||||
let mut c = (1.0 - t).powf(edge_k) as f32;
|
||||
let tone = luma(unpremultiply(colour).0);
|
||||
match kind {
|
||||
PaintDaubBrush::LightRough => c *= 0.7 + 0.35 * rough_bristle(q, 0),
|
||||
PaintDaubBrush::DarkRough => c *= 0.55 + 0.45 * (1.0 - rough_bristle(q, 1)),
|
||||
PaintDaubBrush::Sparkle if rough_bristle(center, 2) > 0.92 && tone > 0.55 => c = c.max(0.85),
|
||||
PaintDaubBrush::Simple | PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry | PaintDaubBrush::Sparkle => {}
|
||||
}
|
||||
c
|
||||
};
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, out| {
|
||||
if out[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / spacing, q.y / spacing);
|
||||
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut weight = 0.0f32;
|
||||
for k in 0..9 {
|
||||
let Some((center, colour)) = around.get((i, j), k, dab) else { continue };
|
||||
let w = cover(q, center, colour);
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = unpremultiply(colour).0;
|
||||
for (s, v) in sum.iter_mut().zip(c) {
|
||||
*s += v * w;
|
||||
}
|
||||
weight += w;
|
||||
}
|
||||
if weight <= 1e-6 {
|
||||
return;
|
||||
}
|
||||
let c = sum.map(|v| (v / weight).clamp(0.0, 1.0));
|
||||
*out = premultiply(c, f32::from(out[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
fn rough_bristle(p: Point, k: u64) -> f32 {
|
||||
let (Some(i), Some(j)) = (cell(p.x * 3.7), cell(p.y * 3.7)) else { return 0.5 };
|
||||
rand(SALT_DAUB + 3, i, j, k) as f32
|
||||
}
|
||||
|
||||
/// Fresco: coarse brush dabs on a plaster wall. `brush_size` (1..10) sets dab spacing,
|
||||
/// `brush_detail` (0..10) keeps sharper edges when high, and `texture` (1..3) deepens the
|
||||
/// relief. Coverage is kept.
|
||||
pub(super) fn fresco(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush_size: f64, brush_detail: f64, texture: f64) {
|
||||
if !([brush_size, brush_detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let brush_size = brush_size.clamp(1.0, 10.0);
|
||||
let brush_detail = brush_detail.clamp(0.0, 10.0);
|
||||
let texture = texture.round().clamp(1.0, 3.0);
|
||||
let side = 2.0 + 1.8 * brush_size;
|
||||
let sigma = (0.15 * (10.0 - brush_detail) * brush_size / place.px).max(0.0);
|
||||
let soft = Soft::new(px, w, sigma);
|
||||
let tex_strength = 0.08 + 0.07 * (texture - 1.0);
|
||||
let tex_scale = 3.0 / texture;
|
||||
let streak_mix = 0.25 * (1.0 - brush_detail / 10.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
|
||||
let jx = (rand(SALT_FRESCO, i, j, 0) - 0.5) * 0.35 * side;
|
||||
let jy = (rand(SALT_FRESCO, i, j, 1) - 0.5) * 0.35 * side;
|
||||
let sample = Point::new(q.x + jx, q.y + jy);
|
||||
let mut sampled = soft.at(place.index(sample));
|
||||
if sampled[3] <= 0.0 {
|
||||
sampled = soft.at(place.index(q));
|
||||
}
|
||||
if sampled[3] <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let [r, g, b, alpha] = sampled;
|
||||
let c = [r, g, b].map(|v| v / alpha);
|
||||
let angle = TAU * rand(SALT_FRESCO, i, j, 2);
|
||||
let (fx, fy) = (q.x - i as f64 * side, q.y - j as f64 * side);
|
||||
let along = fx * angle.cos() + fy * angle.sin();
|
||||
let streak = (1.0 - streak_mix * (0.5 + 0.5 * (along / side).sin().powi(2))) as f32;
|
||||
let bump = (1.0 + tex_strength * smooth_noise(SALT_FRESCO + 3, q, tex_scale * side)) as f32;
|
||||
let lit = c.map(|v| (v * streak * bump).clamp(0.0, 1.0));
|
||||
*p = premultiply(lit, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// How far (document units) a Fresco dab can reach from a pixel.
|
||||
pub(super) fn fresco_reach(brush_size: f64, brush_detail: f64) -> f64 {
|
||||
let brush_size = brush_size.clamp(1.0, 10.0);
|
||||
let brush_detail = brush_detail.clamp(0.0, 10.0);
|
||||
let side = 2.0 + 1.8 * brush_size;
|
||||
side + 0.15 * (10.0 - brush_detail) * brush_size
|
||||
}
|
||||
|
||||
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
|
||||
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
|
||||
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
|
||||
@@ -314,11 +553,144 @@ pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
});
|
||||
}
|
||||
|
||||
/// Stroke length (document units) for Dry Brush at Brush Size `size` (1..10).
|
||||
fn dry_brush_length(size: f64) -> f64 {
|
||||
2.0 + 2.8 * (size.clamp(1.0, 10.0) - 1.0)
|
||||
}
|
||||
|
||||
/// How much Dry Brush softens before smearing at Brush Detail `detail` (0..10; 10 keeps the most detail).
|
||||
fn dry_brush_sigma(detail: f64) -> f64 {
|
||||
0.22 * (10.0 - detail.clamp(0.0, 10.0))
|
||||
}
|
||||
|
||||
/// How far (document units) Dry Brush reads from a pixel.
|
||||
pub(super) fn dry_brush_reach(size: f64, detail: f64) -> f64 {
|
||||
stroke_reach(dry_brush_length(size)) + 3.0 * dry_brush_sigma(detail) + MARGIN
|
||||
}
|
||||
|
||||
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
|
||||
pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
0.25 * size.abs().min(24.0)
|
||||
}
|
||||
|
||||
/// Underpainting: broad brush strokes (`brush_size`, 0..40) over a relief `texture` surface
|
||||
/// (`scaling`, `relief`, `light`, `invert`, as Texturizer uses), blended in as strongly as
|
||||
/// `texture_coverage` (0..40) says. Coverage is kept.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn underpainting(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
brush_size: f64,
|
||||
texture_coverage: f64,
|
||||
texture: &Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
invert: bool,
|
||||
) {
|
||||
if !(brush_size.is_finite() && texture_coverage.is_finite() && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let brush_size = brush_size.clamp(0.0, 40.0);
|
||||
let coverage = (texture_coverage / 40.0).clamp(0.0, 1.0) as f32;
|
||||
let (mut colour, alpha) = planes(px);
|
||||
let sigma = (0.1 * brush_size.max(0.25)) / space.px;
|
||||
if sigma > 0.0 {
|
||||
soften(&mut colour, &alpha, w, h, sigma);
|
||||
}
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let c = [colour[0][k], colour[1][k], colour[2][k]];
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
if coverage <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let softened = px.to_vec();
|
||||
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert);
|
||||
if coverage >= 1.0 {
|
||||
return;
|
||||
}
|
||||
let mix = 1.0 - coverage;
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let tex = straight(*p);
|
||||
let soft = straight(softened[k]);
|
||||
let c = [
|
||||
(soft[0] * mix + tex[0] * coverage).clamp(0.0, 1.0),
|
||||
(soft[1] * mix + tex[1] * coverage).clamp(0.0, 1.0),
|
||||
(soft[2] * mix + tex[2] * coverage).clamp(0.0, 1.0),
|
||||
];
|
||||
*p = premultiply(c, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) Rough Pastels reaches with strokes `len` long.
|
||||
pub(super) fn rough_pastels_reach(len: f64) -> f64 {
|
||||
super::brushstrokes::stroke_reach(len).max(super::brushstrokes::TONE_REACH)
|
||||
}
|
||||
|
||||
/// Rough Pastels: soft diagonal pastel strokes `length` (0..40) long with bristle `detail` (1..20),
|
||||
/// then the shared Texturizer surface (`texture`, `scaling`, `relief`, `light`, `invert`).
|
||||
/// Coverage is kept.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn rough_pastels(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
length: f64,
|
||||
detail: f64,
|
||||
texture: &Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
invert: bool,
|
||||
) {
|
||||
if !([length, detail, scaling, relief].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let length = length.clamp(0.0, 40.0);
|
||||
let detail = detail.clamp(1.0, 20.0);
|
||||
if length > 0.0 {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, length.max(1.0));
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let row = (0.18 * smear.len).max(1.0);
|
||||
let stroke_mix = (0.35 + 0.0225 * detail).clamp(0.35, 0.8) as f32;
|
||||
let bristle = (0.015 + 0.0035 * detail) as f32;
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let pm = mix(smear.stroke(SALT_PASTEL, q, RIGHT, row), smear.stroke(SALT_PASTEL + 1, q, LEFT, row), 0.5);
|
||||
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
|
||||
let pm = mix(orig, pm, stroke_mix);
|
||||
let (mut c, a) = unpremultiply(pm);
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let dir = if tones.get(x, y) > 0.52 { RIGHT } else { LEFT };
|
||||
let n = bristle * streak(SALT_PASTEL + 2, q, dir, smear.len.max(1.0), row) as f32;
|
||||
c = c.map(|v| (v + n).clamp(0.0, 1.0));
|
||||
*p = premultiply(c, orig[3]);
|
||||
});
|
||||
}
|
||||
if relief > 0.0 {
|
||||
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
|
||||
}
|
||||
}
|
||||
|
||||
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
|
||||
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
|
||||
/// strong as `brightness` (0..50). Coverage is kept.
|
||||
@@ -343,3 +715,162 @@ pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tone at `(x, y)` in `tone`, `w` × `h`, or 0 outside.
|
||||
fn tone_at(tone: &[f32], w: usize, h: usize, x: isize, y: isize) -> f32 {
|
||||
if x < 0 || y < 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (x, y) = (x as usize, y as usize);
|
||||
if x >= w || y >= h {
|
||||
return 0.0;
|
||||
}
|
||||
tone[y * w + x]
|
||||
}
|
||||
|
||||
/// A unit vector along the image gradient's tangent at `(x, y)`, or horizontal when flat.
|
||||
fn dry_brush_dir(tone: &[f32], w: usize, h: usize, x: usize, y: usize) -> Vec2 {
|
||||
let gx = 0.5 * (tone_at(tone, w, h, x as isize + 1, y as isize) - tone_at(tone, w, h, x as isize - 1, y as isize));
|
||||
let gy = 0.5 * (tone_at(tone, w, h, x as isize, y as isize + 1) - tone_at(tone, w, h, x as isize, y as isize - 1));
|
||||
let v = Vec2::new(-f64::from(gy), f64::from(gx));
|
||||
let len = v.hypot();
|
||||
if len > 1e-6 { v / len } else { Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA (0..1) at document `q`, bilinearly from `src`.
|
||||
fn sample_pm(src: &[[f32; 4]], w: usize, h: usize, place: &Place, q: Point) -> [f32; 4] {
|
||||
let p = place.to_px * q;
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let (x, y) = (p.x - 0.5, p.y - 0.5);
|
||||
let (i, j) = (x.floor() as isize, y.floor() as isize);
|
||||
let (fx, fy) = (x - i as f64, y - j as f64);
|
||||
let mut acc = [0.0f32; 4];
|
||||
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
|
||||
let Some(row) = usize::try_from(row).ok().filter(|r| *r < h) else { continue };
|
||||
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
|
||||
let Some(col) = usize::try_from(col).ok().filter(|c| *c < w) else { continue };
|
||||
let k = (wx * wy) as f32;
|
||||
if let Some(p) = src.get(row * w + col) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += *v * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Dry Brush: the object repainted with short strokes along its form, edges darkened. `size`
|
||||
/// (1..10) sets the stroke length, `detail` (0..10) how much is softened first (10 keeps the most
|
||||
/// detail), and `texture` (1..3) how strongly edges and bristle grain contrast. Coverage is kept.
|
||||
pub(super) fn dry_brush(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, detail: f64, texture: f64) {
|
||||
if !([size, detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let len = dry_brush_length(size);
|
||||
let sigma = dry_brush_sigma(detail);
|
||||
let texture = texture.clamp(1.0, 3.0);
|
||||
let edge_gain = (0.25 + 0.2 * (texture - 1.0)) as f32;
|
||||
let bristle = (0.04 + 0.05 * (texture - 1.0)) as f32;
|
||||
let (mut colour, alpha) = planes(px);
|
||||
if sigma > 0.0 {
|
||||
soften(&mut colour, &alpha, w, h, sigma);
|
||||
}
|
||||
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
|
||||
let tone: Vec<f32> = (0..w * h)
|
||||
.map(|k| {
|
||||
let c = at(k);
|
||||
let a = alpha.get(k).copied().unwrap_or(0.0);
|
||||
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) * a
|
||||
})
|
||||
.collect();
|
||||
let edges = sobel(&tone, w, h);
|
||||
let src: Vec<[f32; 4]> = (0..w * h)
|
||||
.map(|k| {
|
||||
let c = at(k);
|
||||
let a = alpha.get(k).copied().unwrap_or(0.0);
|
||||
[c[0] * a, c[1] * a, c[2] * a, a]
|
||||
})
|
||||
.collect();
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
const SAMPLES: usize = 5;
|
||||
let mut out = px.to_vec();
|
||||
for (k, p) in out.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (k % w, k / w);
|
||||
let q = place.at(x, y);
|
||||
let dir = dry_brush_dir(&tone, w, h, x, y);
|
||||
let mut acc = [0.0f32; 4];
|
||||
for i in 0..SAMPLES {
|
||||
let t = (i as f64 / (SAMPLES - 1) as f64 - 0.5) * len;
|
||||
let s = sample_pm(&src, w, h, &place, q + dir * t);
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += v;
|
||||
}
|
||||
}
|
||||
for a in &mut acc {
|
||||
*a /= SAMPLES as f32;
|
||||
}
|
||||
let a = acc[3].clamp(0.0, 1.0);
|
||||
let c = if a > 1e-6 { [0, 1, 2].map(|i| (acc[i] / a).clamp(0.0, 1.0)) } else { straight(*p) };
|
||||
let edge = edges[k].min(1.0);
|
||||
let dark = (edge_gain * edge).clamp(0.0, 0.75);
|
||||
let grain = bristle * (2.0 * value_noise(SALT_DRY, q.x / (5.0 * space.px), q.y / (5.0 * space.px)) - 1.0) as f32;
|
||||
let c = c.map(|v| (v * (1.0 - dark) + grain).clamp(0.0, 1.0));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
px.copy_from_slice(&out);
|
||||
}
|
||||
|
||||
const SALT_KNIFE: u64 = 208;
|
||||
|
||||
/// How far Palette Knife reaches with strokes `stroke_size` long and Softness `softness` (0..10).
|
||||
pub(super) fn palette_knife_reach(stroke_size: f64, softness: f64) -> f64 {
|
||||
stroke_reach(stroke_size) + 3.0 * 0.35 * softness.clamp(0.0, 10.0) + 1.0
|
||||
}
|
||||
|
||||
/// Artistic › Palette Knife: the object repainted with short, angled knife strokes that smear its
|
||||
/// colours; `stroke_size` (1..50) sets their length, `stroke_detail` (1..3) keeps more of the
|
||||
/// original where it's higher, and `softness` (0..10) softens the strokes. Coverage is kept.
|
||||
pub(super) fn palette_knife(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke_size: f64, stroke_detail: f64, softness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(stroke_size.is_finite() && stroke_detail.is_finite() && softness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let len = stroke_size.clamp(1.0, 50.0);
|
||||
let keep = ((stroke_detail.clamp(1.0, 3.0) - 1.0) / 2.0) as f32;
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let row = (0.25 * smear.len).max(1.0);
|
||||
let cell = len * 0.35;
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let angle = TAU * value_noise(SALT_KNIFE, q.x / cell, q.y / cell);
|
||||
let dir = Vec2::new(angle.cos(), angle.sin());
|
||||
let pm = smear.stroke(SALT_KNIFE + 1, q, dir, row);
|
||||
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
|
||||
let coverage = orig[3];
|
||||
let (c, _) = unpremultiply(mix(orig, pm, 1.0 - keep));
|
||||
*p = premultiply(c, coverage);
|
||||
});
|
||||
let sigma = 0.35 * softness.clamp(0.0, 10.0) / place.px;
|
||||
if sigma > 0.0 {
|
||||
let (mut colour, alpha) = planes(px);
|
||||
let cover = alpha.clone();
|
||||
soften(&mut colour, &alpha, w, h, sigma);
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
if cover[k] == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = [colour[0][k], colour[1][k], colour[2][k]];
|
||||
*p = premultiply(c, cover[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ fn frame(q: Point, dir: Vec2) -> (f64, f64) {
|
||||
/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width`
|
||||
/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] ×
|
||||
/// `len`.
|
||||
fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
|
||||
pub(super) fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
|
||||
if !(len > 0.0 && width > 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
+144
-14
@@ -1,9 +1,9 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and
|
||||
//! Poster Edges, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Plastic Wrap and
|
||||
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
||||
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
|
||||
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
|
||||
//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Plaster and
|
||||
//! Reticulation, Stylize ›
|
||||
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
||||
//! Reticulation, Stamp and Torn Edges, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
@@ -34,23 +34,31 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use artistic::{PAINT_DAUB_BRUSH_TYPES, PaintDaubBrush};
|
||||
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
|
||||
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 46] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 55] = [
|
||||
"artistic.coloredPencil",
|
||||
"artistic.cutout",
|
||||
"artistic.dryBrush",
|
||||
"artistic.filmGrain",
|
||||
"artistic.fresco",
|
||||
"artistic.neonGlow",
|
||||
"artistic.paintDaubs",
|
||||
"artistic.paletteKnife",
|
||||
"artistic.plasticWrap",
|
||||
"artistic.posterEdges",
|
||||
"artistic.roughPastels",
|
||||
"artistic.smudgeStick",
|
||||
"artistic.sponge",
|
||||
"artistic.underpainting",
|
||||
"artistic.watercolor",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
@@ -81,6 +89,8 @@ pub const PIXEL_EFFECTS: [&str; 46] = [
|
||||
"sketch.plaster",
|
||||
"sketch.reticulation",
|
||||
"sketch.stamp",
|
||||
"sketch.tornEdges",
|
||||
"sketch.waterPaper",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -96,12 +106,22 @@ pub const PIXEL_EFFECTS: [&str; 46] = [
|
||||
/// are in document units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Artistic › Colored Pencil: coloured strokes `width` (1..24) apart, as heavy as `pressure`
|
||||
/// (0..15), on paper lifted by `paper` (0..50).
|
||||
ColoredPencil { width: f64, pressure: f64, paper: f64 },
|
||||
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
|
||||
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
|
||||
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
|
||||
/// Artistic › Dry Brush: short strokes along the form with darkened edges; `size` (1..10) is the
|
||||
/// brush length, `detail` (0..10) how much detail is kept, `texture` (1..3) the edge and bristle
|
||||
/// contrast.
|
||||
DryBrush { size: f64, detail: f64, texture: f64 },
|
||||
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
|
||||
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
|
||||
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Fresco: coarse brush dabs on plaster; `brush_size` (1..10), `brush_detail`
|
||||
/// (0..10) and `texture` (1..3).
|
||||
Fresco { brush_size: f64, brush_detail: f64, texture: f64 },
|
||||
/// Artistic › Smudge Stick: the dark areas smudged along short diagonal strokes `stroke`
|
||||
/// (0..10) long, the light areas (widened by `highlight`, 0..20) smudged longer and lightened
|
||||
/// by up to `intensity` (0..10).
|
||||
@@ -116,12 +136,27 @@ pub enum PixelFx {
|
||||
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
|
||||
/// strong as `brightness` (0..50).
|
||||
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
|
||||
/// Artistic › Paint Daubs: dabs `size` (1..50) points apart, as sharp as `sharpness` (0..40),
|
||||
/// with brush `kind`.
|
||||
PaintDaubs { size: f64, sharpness: f64, kind: PaintDaubBrush },
|
||||
/// Artistic › Palette Knife: knife strokes `stroke_size` (1..50) long smear the colours;
|
||||
/// `stroke_detail` (1..3) keeps more original detail when higher; `softness` (0..10) softens
|
||||
/// the strokes.
|
||||
PaletteKnife { stroke_size: f64, stroke_detail: f64, softness: f64 },
|
||||
/// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief
|
||||
/// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15).
|
||||
PlasticWrap { highlight: f64, detail: f64, smoothness: f64 },
|
||||
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
|
||||
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
|
||||
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
|
||||
/// Artistic › Underpainting: colours softened as widely as `brush_size` (0..40), then a
|
||||
/// `texture` surface (`scaling`, `relief`, `light`, `invert`) blended in as strongly as
|
||||
/// `texture_coverage` (0..40) says.
|
||||
Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail
|
||||
/// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as
|
||||
/// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
|
||||
RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
@@ -202,6 +237,14 @@ pub enum PixelFx {
|
||||
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
|
||||
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
|
||||
Reticulation { density: f64, foreground: f64, background: f64 },
|
||||
/// Sketch › Torn Edges: high-contrast black and white with ragged edges; `image_balance`
|
||||
/// (0..50) shifts ink versus paper, `smoothness` (0..15) softens tones and fraying, `contrast`
|
||||
/// (0..20) hardens the split.
|
||||
TornEdges { image_balance: f64, smoothness: f64, contrast: f64 },
|
||||
/// Sketch › Water Paper: colours flow on damp fibrous paper; `fiber_length` (3..50) sets the
|
||||
/// fibers and daubs, `brightness` and `contrast` (0..100) adjust the wash. Ranges and defaults
|
||||
/// are unverified VectorCraft choices (helpx documents the controls only).
|
||||
WaterPaper { fiber_length: f64, brightness: f64, contrast: f64 },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -282,16 +325,31 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"artistic.coloredPencil" => PixelFx::ColoredPencil {
|
||||
width: num(p, "pencilWidth", 5.0).clamp(1.0, 24.0),
|
||||
pressure: num(p, "strokePressure", 5.0).clamp(0.0, 15.0),
|
||||
paper: num(p, "paperBrightness", 25.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"artistic.cutout" => PixelFx::Cutout {
|
||||
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
|
||||
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
|
||||
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
|
||||
},
|
||||
"artistic.dryBrush" => PixelFx::DryBrush {
|
||||
size: num(p, "brushSize", 2.0).clamp(1.0, 10.0),
|
||||
detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0),
|
||||
texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0),
|
||||
},
|
||||
"artistic.neonGlow" => PixelFx::NeonGlow {
|
||||
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
|
||||
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
|
||||
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
|
||||
},
|
||||
"artistic.paletteKnife" => PixelFx::PaletteKnife {
|
||||
stroke_size: num(p, "strokeSize", 10.0).clamp(1.0, 50.0),
|
||||
stroke_detail: num(p, "strokeDetail", 2.0).clamp(1.0, 3.0),
|
||||
softness: num(p, "softness", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.posterEdges" => PixelFx::PosterEdges {
|
||||
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
|
||||
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
|
||||
@@ -302,6 +360,29 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.underpainting" => PixelFx::Underpainting {
|
||||
brush_size: num(p, "brushSize", 6.0).clamp(0.0, 40.0),
|
||||
texture_coverage: num(p, "textureCoverage", 16.0).clamp(0.0, 40.0),
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"artistic.roughPastels" => PixelFx::RoughPastels {
|
||||
length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0),
|
||||
detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0),
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"artistic.fresco" => PixelFx::Fresco {
|
||||
brush_size: num(p, "brushSize", 2.0).clamp(1.0, 10.0),
|
||||
brush_detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0),
|
||||
texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0),
|
||||
},
|
||||
"artistic.smudgeStick" => PixelFx::SmudgeStick {
|
||||
stroke: num(p, "strokeLength", 2.0).clamp(0.0, 10.0),
|
||||
highlight: num(p, "highlightArea", 12.0).clamp(0.0, 20.0),
|
||||
@@ -317,6 +398,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
shadow: num(p, "shadowIntensity", 1.0).clamp(0.0, 10.0),
|
||||
texture: num(p, "texture", 1.0).clamp(1.0, 3.0).round() as u32,
|
||||
},
|
||||
"artistic.paintDaubs" => PixelFx::PaintDaubs {
|
||||
size: num(p, "brushSize", 10.0).clamp(1.0, 50.0),
|
||||
sharpness: num(p, "sharpness", 25.0).clamp(0.0, 40.0),
|
||||
kind: PaintDaubBrush::parse(text(p, "brushType", "simple")),
|
||||
},
|
||||
"artistic.plasticWrap" => PixelFx::PlasticWrap {
|
||||
highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0),
|
||||
detail: num(p, "detail", 12.0).clamp(0.0, 15.0),
|
||||
@@ -438,6 +524,16 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"sketch.tornEdges" => PixelFx::TornEdges {
|
||||
image_balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
|
||||
smoothness: num(p, "smoothness", 11.0).clamp(0.0, 15.0),
|
||||
contrast: num(p, "contrast", 17.0).clamp(0.0, 20.0),
|
||||
},
|
||||
"sketch.waterPaper" => PixelFx::WaterPaper {
|
||||
fiber_length: num(p, "fiberLength", 15.0).clamp(3.0, 50.0),
|
||||
brightness: num(p, "brightness", 50.0).clamp(0.0, 100.0),
|
||||
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -475,7 +571,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.texturizer" => PixelFx::Texturizer {
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
@@ -503,9 +599,11 @@ impl PixelFx {
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::AccentedEdges { .. } => 0.0,
|
||||
// Strokes reach out of the object as far as they reach into it.
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } | PixelFx::InkOutlines { length, .. } => {
|
||||
brushstrokes::stroke_reach(length)
|
||||
}
|
||||
PixelFx::PaletteKnife { stroke_size, softness, .. } => artistic::palette_knife_reach(stroke_size, softness),
|
||||
PixelFx::AngledStrokes { length, .. }
|
||||
| PixelFx::GraphicPen { length, .. }
|
||||
| PixelFx::InkOutlines { length, .. }
|
||||
| PixelFx::RoughPastels { length, .. } => brushstrokes::stroke_reach(length),
|
||||
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
|
||||
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
|
||||
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
|
||||
@@ -522,16 +620,23 @@ impl PixelFx {
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Plaster { .. }
|
||||
| PixelFx::Reticulation { .. } => 0.0,
|
||||
| PixelFx::Reticulation { .. }
|
||||
| PixelFx::TornEdges { .. } => 0.0,
|
||||
PixelFx::BasRelief { .. } | PixelFx::Charcoal { .. } | PixelFx::Photocopy { .. } | PixelFx::Stamp { .. } => 0.0,
|
||||
PixelFx::WaterPaper { fiber_length, .. } => sketch::water_paper_shift(fiber_length),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::PaintDaubs { size, .. } => artistic::paint_daub_radius(size),
|
||||
// These three keep the object's coverage.
|
||||
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::ColoredPencil { .. }
|
||||
| PixelFx::Fresco { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
| PixelFx::DryBrush { .. }
|
||||
| PixelFx::NeonGlow { .. }
|
||||
| PixelFx::PlasticWrap { .. }
|
||||
| PixelFx::PosterEdges { .. }
|
||||
| PixelFx::Underpainting { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
@@ -559,9 +664,11 @@ impl PixelFx {
|
||||
PixelFx::Watercolor { detail, .. } => Some(paint::watercolor_reach(detail)),
|
||||
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
|
||||
// The strokes, and the softened tones that choose them.
|
||||
PixelFx::PaletteKnife { stroke_size, softness, .. } => Some(artistic::palette_knife_reach(stroke_size, softness)),
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => {
|
||||
Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
PixelFx::RoughPastels { length, .. } => Some(artistic::rough_pastels_reach(length)),
|
||||
PixelFx::Crosshatch { length, .. } => {
|
||||
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
@@ -582,6 +689,7 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
|
||||
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
|
||||
PixelFx::FilmGrain { .. }
|
||||
@@ -589,14 +697,21 @@ impl PixelFx {
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Reticulation { .. } => Some(0.0),
|
||||
PixelFx::Fresco { brush_size, brush_detail, .. } => Some(artistic::fresco_reach(brush_size, brush_detail)),
|
||||
PixelFx::DryBrush { size, detail, .. } => Some(artistic::dry_brush_reach(size, detail)),
|
||||
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
PixelFx::PaintDaubs { size, .. } => Some(artistic::paint_daub_radius(size) + size * 0.15),
|
||||
PixelFx::WaterPaper { fiber_length, .. } => {
|
||||
Some(sketch::water_paper_shift(fiber_length) + 3.0 * (0.06 * fiber_length.clamp(3.0, 50.0)).max(0.35))
|
||||
}
|
||||
// Cutout's palette and Torn Edges' peak-normalised edges come from the whole object.
|
||||
PixelFx::Cutout { .. } | PixelFx::TornEdges { .. } => None,
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
|
||||
PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)),
|
||||
// The edges' lines, spread round them.
|
||||
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
|
||||
PixelFx::Underpainting { brush_size, .. } => Some(3.0 * (0.1 * brush_size.max(0.25)) + 1.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -617,16 +732,29 @@ impl PixelFx {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::ColoredPencil { width, pressure, paper } => artistic::colored_pencil(px, w, h, space, width, pressure, paper),
|
||||
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
|
||||
PixelFx::DryBrush { size, detail, texture } => artistic::dry_brush(px, w, h, space, size, detail, texture),
|
||||
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::Fresco { brush_size, brush_detail, texture } => artistic::fresco(px, w, h, space, brush_size, brush_detail, texture),
|
||||
PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity),
|
||||
PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness),
|
||||
PixelFx::Watercolor { detail, shadow, texture } => paint::watercolor(px, w, h, space, detail, shadow, texture),
|
||||
PixelFx::PaintDaubs { size, sharpness, kind } => artistic::paint_daubs(px, w, h, space, size, sharpness, kind),
|
||||
PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness),
|
||||
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
|
||||
PixelFx::PaletteKnife { stroke_size, stroke_detail, softness } => {
|
||||
artistic::palette_knife(px, w, h, space, stroke_size, stroke_detail, softness)
|
||||
}
|
||||
PixelFx::PosterEdges { thickness, intensity, posterization } => {
|
||||
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
|
||||
}
|
||||
PixelFx::Underpainting { brush_size, texture_coverage, ref texture, scaling, relief, light, invert } => {
|
||||
artistic::underpainting(px, w, h, space, brush_size, texture_coverage, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::RoughPastels { length, detail, ref texture, scaling, relief, light, invert } => {
|
||||
artistic::rough_pastels(px, w, h, space, length, detail, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
|
||||
blur::smart(px, w, h, to_px(radius), threshold, samples)
|
||||
@@ -659,6 +787,8 @@ impl PixelFx {
|
||||
PixelFx::Stamp { balance, smoothness } => sketch_ink::stamp(px, w, h, space, balance, smoothness),
|
||||
PixelFx::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light),
|
||||
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
|
||||
PixelFx::TornEdges { image_balance, smoothness, contrast } => sketch::torn_edges(px, w, h, space, image_balance, smoothness, contrast),
|
||||
PixelFx::WaterPaper { fiber_length, brightness, contrast } => sketch::water_paper(px, w, h, space, fiber_length, brightness, contrast),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
@@ -669,7 +799,7 @@ impl PixelFx {
|
||||
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
|
||||
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
|
||||
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
|
||||
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
|
||||
PixelFx::Texturizer { ref texture, scaling, relief, light, invert } => {
|
||||
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster and Reticulation; Graphic Pen lives in
|
||||
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
||||
//! Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
|
||||
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! black and white; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! approximations, not proprietary reproductions.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||
|
||||
use vectorcraft_geom::Vec2;
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::distort::displace;
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand, value_noise};
|
||||
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
|
||||
@@ -15,6 +17,8 @@ use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_CHALK: u64 = 216;
|
||||
const SALT_CHROME: u64 = 0xC40_4D45;
|
||||
const SALT_TORN: u64 = 0x746f726e5f6564;
|
||||
const SALT_WATER: u64 = 208;
|
||||
const EDGE_SOFT: f64 = 0.8;
|
||||
const TONE_SOFT: f64 = 1.0;
|
||||
/// Light from the top left (document axes, y down), as on polished relief surfaces.
|
||||
@@ -357,3 +361,122 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
|
||||
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
|
||||
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
|
||||
pub(super) fn torn_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, contrast: f64) {
|
||||
if !(image_balance.is_finite() && smoothness.is_finite() && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) {
|
||||
return;
|
||||
}
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let sigma = (0.12 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9);
|
||||
if sigma > 0.0 {
|
||||
blur_plane(&mut tone, w, h, sigma);
|
||||
}
|
||||
let mut edges: Vec<f32> = sobel(&tone, w, h).into_iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c)).collect();
|
||||
let edge_soft = (0.08 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9);
|
||||
if edge_soft > 0.0 {
|
||||
blur_plane(&mut edges, w, h, edge_soft);
|
||||
}
|
||||
let peak = edges.iter().copied().fold(0.0f32, f32::max);
|
||||
let balance = image_balance.clamp(0.0, 50.0);
|
||||
// Lower balance favours ink (darker result); higher balance favours paper.
|
||||
let balance_tone = 0.25 + balance / 50.0 * 0.75;
|
||||
let steep = 4.0 + contrast.clamp(0.0, 20.0) * 0.45;
|
||||
let hard = contrast.clamp(0.0, 20.0) >= 18.0;
|
||||
let fray = 0.25 + 0.05 * smoothness.clamp(0.0, 15.0);
|
||||
place.each(px, |x, y, p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
let t = tone.get(i).copied().unwrap_or(0.0);
|
||||
let e = if peak > 1e-6 { edges.get(i).copied().unwrap_or(0.0) / peak } else { 0.0 };
|
||||
let noise = smooth_noise(SALT_TORN, place.at(x, y), 2.5 + 0.3 * smoothness) as f32;
|
||||
let local = (1.0 - balance_tone) as f32 + e * fray as f32 * noise;
|
||||
let mut ink = 1.0 / (1.0 + (steep as f32 * (t - local)).exp());
|
||||
if hard {
|
||||
ink = if ink > 0.5 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let paper = 1.0 - ink.clamp(0.0, 1.0);
|
||||
*p = premultiply([paper, paper, paper], a);
|
||||
});
|
||||
}
|
||||
|
||||
/// How far Water Paper's blotchy displacement can shift content (document units).
|
||||
pub(super) fn water_paper_shift(fiber_length: f64) -> f64 {
|
||||
0.12 * fiber_length.clamp(3.0, 50.0)
|
||||
}
|
||||
|
||||
/// Water Paper: colours flow on damp fibrous paper. `fiber_length` (3..50) sets the scale of the
|
||||
/// fibers and daubs; `brightness` and `contrast` (0..100) adjust the washed result. Ranges and
|
||||
/// defaults are documented in [`crate::effect_catalog`] (unverified; helpx lists the controls only).
|
||||
pub(super) fn water_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, fiber_length: f64, brightness: f64, contrast: f64) {
|
||||
if !(fiber_length.is_finite() && brightness.is_finite() && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let fiber = fiber_length.clamp(3.0, 50.0);
|
||||
let bright = (brightness / 100.0).clamp(0.0, 1.0);
|
||||
let cont = (contrast / 100.0).clamp(0.0, 1.0);
|
||||
let shift = water_paper_shift(fiber);
|
||||
let wave = fiber * 0.4;
|
||||
displace(px, w, h, &place, shift, |q| {
|
||||
let a = 0.65 * smooth_noise(SALT_WATER, q, wave) + 0.35 * smooth_noise(SALT_WATER + 3, q, wave * 1.7);
|
||||
let b = 0.65 * smooth_noise(SALT_WATER + 1, q, wave * 1.1) + 0.35 * smooth_noise(SALT_WATER + 4, q, wave * 0.85);
|
||||
Vec2::new(a, b) * shift
|
||||
});
|
||||
let flow = (0.06 * fiber).max(0.35) / place.px.max(1e-9);
|
||||
let mut planes: [Vec<f32>; 3] = std::array::from_fn(|ch| px.iter().map(|p| straight(*p)[ch] * f32::from(p[3]) / 255.0).collect());
|
||||
for plane in &mut planes {
|
||||
blur_plane(plane, w, h, flow);
|
||||
}
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = {
|
||||
let mut rgb = [0.0f32; 3];
|
||||
let mut ok = true;
|
||||
for ch in 0..3 {
|
||||
let Some(v) = planes[ch].get(i) else {
|
||||
ok = false;
|
||||
break;
|
||||
};
|
||||
rgb[ch] = (*v / a).clamp(0.0, 1.0);
|
||||
}
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
rgb
|
||||
};
|
||||
*p = premultiply(rgb, a);
|
||||
}
|
||||
let lift = (bright - 0.5) * 0.35;
|
||||
let gain = 0.35 + 0.65 * cont;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let along = smooth_noise(SALT_WATER + 5, q, fiber * 0.25);
|
||||
let across = smooth_noise(SALT_WATER + 6, Point::new(q.y, q.x), fiber * 0.22);
|
||||
let fiber_tex = (0.5 * (along + across) + 0.5).clamp(0.0, 1.0);
|
||||
let paper = (0.82 + 0.18 * fiber_tex) as f32;
|
||||
let mut rgb = straight(*p).map(|v| ((v + lift as f32 - 0.5) * gain as f32 + 0.5).clamp(0.0, 1.0));
|
||||
rgb = rgb.map(|v| (v * paper).clamp(0.0, 1.0));
|
||||
*p = premultiply(rgb, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -1222,6 +1222,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
|
||||
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
|
||||
("texture.texturizer", "texture", TEXTURES.as_slice()),
|
||||
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
("artistic.roughPastels", "texture", TEXTURES.as_slice()),
|
||||
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
] {
|
||||
let doc = crate::effect_info(id).unwrap().params;
|
||||
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
||||
@@ -1426,6 +1428,29 @@ fn texturizer_lights_each_surface_from_its_side() {
|
||||
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturizer_draws_a_loaded_texture_as_relief() {
|
||||
let w = 64;
|
||||
let mid = grey(150);
|
||||
let rings = stored(12, 10, |x, y| {
|
||||
let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs();
|
||||
let g = (v * 255.0).round() as u8;
|
||||
[g, g, g, 255]
|
||||
});
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
|
||||
d
|
||||
};
|
||||
let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20}));
|
||||
assert_ne!(relief, flat(w, mid));
|
||||
for (_, builtin) in TEXTURES {
|
||||
assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}");
|
||||
}
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid));
|
||||
assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({})));
|
||||
}
|
||||
|
||||
/// The Texture filters' patterns lie in document space around the object's centre: the same at
|
||||
/// twice the resolution, and moving with the object.
|
||||
#[test]
|
||||
@@ -2046,6 +2071,20 @@ fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace
|
||||
out
|
||||
}
|
||||
|
||||
/// Torn Edges of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn torn(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.tornEdges", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// Water Paper of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn water_paper(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.waterPaper", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest period of `values` (their length when they don't repeat).
|
||||
fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
@@ -2144,6 +2183,35 @@ fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
|
||||
assert_ne!(flat, grainy, "graininess adds fibre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_paper_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.waterPaper", json!({})), PixelFx::WaterPaper { fiber_length: 15.0, brightness: 50.0, contrast: 50.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.waterPaper", json!({"fiberLength": 1, "brightness": -5, "contrast": 200})),
|
||||
PixelFx::WaterPaper { fiber_length: 3.0, brightness: 0.0, contrast: 100.0 }
|
||||
);
|
||||
let f = fx("sketch.waterPaper", json!({"fiberLength": 40}));
|
||||
assert!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)) > 0.0);
|
||||
assert!(f.reach().unwrap_or(0.0) > 0.0);
|
||||
let doc = crate::effect_info("sketch.waterPaper").unwrap().params;
|
||||
assert!(doc.contains("unverified"), "{doc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_paper_softens_and_textures_a_flat_fill_without_losing_coverage() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| [200, 80, 40, 255]);
|
||||
let out = water_paper(json!({"fiberLength": 20, "brightness": 50, "contrast": 40}), &src, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().filter(|p| p[3] > 200).count() > w * h / 2, "most of the fill stays visible");
|
||||
assert_ne!(out, src, "the wash changes pixels");
|
||||
let spread = |d: &[u8]| {
|
||||
let reds: Vec<u8> = d.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let (min, max) = (reds.iter().min().unwrap(), reds.iter().max().unwrap());
|
||||
max - min
|
||||
};
|
||||
assert!(spread(&out) > spread(&src) / 2, "fibers add tonal variation");
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
@@ -2343,6 +2411,63 @@ fn reticulation_density_changes_the_grain() {
|
||||
assert_ne!(low, high);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_edges_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.tornEdges", json!({})), PixelFx::TornEdges { image_balance: 25.0, smoothness: 11.0, contrast: 17.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.tornEdges", json!({"imageBalance": 0, "smoothness": 99, "contrast": -1})),
|
||||
PixelFx::TornEdges { image_balance: 0.0, smoothness: 15.0, contrast: 0.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.tornEdges", json!({"imageBalance": 1e9, "smoothness": -3, "contrast": 1e9})),
|
||||
PixelFx::TornEdges { image_balance: 50.0, smoothness: 0.0, contrast: 20.0 }
|
||||
);
|
||||
for junk in [json!({"imageBalance": "x"}), json!({"smoothness": null})] {
|
||||
assert_eq!(fx("sketch.tornEdges", junk), fx("sketch.tornEdges", json!({})));
|
||||
}
|
||||
assert_eq!(fx("sketch.tornEdges", json!({})).reach(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_edges_draws_in_black_and_white_and_keeps_coverage() {
|
||||
let (w, h) = (64, 48);
|
||||
let src = ramp(w, h);
|
||||
let out = torn(json!({"imageBalance": 12, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "black or white: {p:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_edges_image_balance_shifts_ink_on_a_grey_ramp() {
|
||||
let (w, h) = (80, 16);
|
||||
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
|
||||
let params = json!({"imageBalance": 25, "contrast": 20, "smoothness": 5});
|
||||
let out = torn(params, &src, w, h, &space(w, h));
|
||||
let mut dark_ink = 0usize;
|
||||
let mut bright_ink = 0usize;
|
||||
for y in 0..h {
|
||||
for x in 0..w / 4 {
|
||||
if at(&out, w, x, y)[0] == 0 {
|
||||
dark_ink += 1;
|
||||
}
|
||||
}
|
||||
for x in 3 * w / 4..w {
|
||||
if at(&out, w, x, y)[0] == 0 {
|
||||
bright_ink += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let dark_n = (w / 4) * h;
|
||||
let bright_n = (w - 3 * w / 4) * h;
|
||||
assert!(dark_ink as f64 / dark_n as f64 > bright_ink as f64 / bright_n as f64 + 0.2, "dark end of ramp is inkier than bright end");
|
||||
let dark = share(&torn(json!({"imageBalance": 0, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0);
|
||||
let light = share(&torn(json!({"imageBalance": 50, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0);
|
||||
assert!(dark > light + 0.15, "low balance leaves more ink: dark={dark} light={light}");
|
||||
}
|
||||
|
||||
/// The distinct colours of the covered pixels.
|
||||
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
|
||||
@@ -2360,11 +2485,22 @@ fn ramp(w: usize, h: usize) -> Vec<u8> {
|
||||
|
||||
#[test]
|
||||
fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("artistic.coloredPencil", json!({})), PixelFx::ColoredPencil { width: 5.0, pressure: 5.0, paper: 25.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})),
|
||||
PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0)));
|
||||
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
|
||||
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.dryBrush", json!({})), PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.dryBrush", json!({"brushSize": 1e308, "brushDetail": 0, "texture": -2})),
|
||||
PixelFx::DryBrush { size: 10.0, detail: 0.0, texture: 1.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
|
||||
@@ -2377,14 +2513,96 @@ fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
|
||||
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
|
||||
);
|
||||
let under = PixelFx::Underpainting {
|
||||
brush_size: 6.0,
|
||||
texture_coverage: 16.0,
|
||||
texture: Texture::Canvas,
|
||||
scaling: 1.0,
|
||||
relief: 4.0,
|
||||
light: Light::Top,
|
||||
invert: false,
|
||||
};
|
||||
assert_eq!(fx("artistic.underpainting", json!({})), under);
|
||||
assert_eq!(
|
||||
fx(
|
||||
"artistic.underpainting",
|
||||
json!({"brushSize": 99, "textureCoverage": -1, "texture": "brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})
|
||||
),
|
||||
PixelFx::Underpainting {
|
||||
brush_size: 40.0,
|
||||
texture_coverage: 0.0,
|
||||
texture: Texture::Brick,
|
||||
scaling: 0.5,
|
||||
relief: 50.0,
|
||||
light: Light::BottomRight,
|
||||
invert: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
fx("artistic.roughPastels", json!({})),
|
||||
PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false }
|
||||
);
|
||||
assert_eq!(
|
||||
fx(
|
||||
"artistic.roughPastels",
|
||||
json!({"strokeLength": 0, "strokeDetail": 99, "texture": -3, "scaling": 10, "relief": 60, "lightDirection": "up", "invert": true})
|
||||
),
|
||||
PixelFx::RoughPastels { length: 0.0, detail: 20.0, texture: Texture::Canvas, scaling: 0.5, relief: 50.0, light: Light::Top, invert: true }
|
||||
);
|
||||
assert_eq!(fx("artistic.fresco", json!({})), PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.fresco", json!({"brushSize": 0, "brushDetail": 99, "texture": 4.7})),
|
||||
PixelFx::Fresco { brush_size: 1.0, brush_detail: 10.0, texture: 3.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
|
||||
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
|
||||
for id in [
|
||||
"artistic.coloredPencil",
|
||||
"artistic.cutout",
|
||||
"artistic.dryBrush",
|
||||
"artistic.fresco",
|
||||
"artistic.neonGlow",
|
||||
"artistic.plasticWrap",
|
||||
"artistic.posterEdges",
|
||||
"artistic.underpainting",
|
||||
] {
|
||||
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
|
||||
}
|
||||
assert_eq!(fx("artistic.paintDaubs", json!({})), PixelFx::PaintDaubs { size: 10.0, sharpness: 25.0, kind: PaintDaubBrush::Simple });
|
||||
assert_eq!(
|
||||
fx("artistic.paintDaubs", json!({"brushSize": 0, "sharpness": 99, "brushType": "wideBlurry"})),
|
||||
PixelFx::PaintDaubs { size: 1.0, sharpness: 40.0, kind: PaintDaubBrush::WideBlurry }
|
||||
);
|
||||
let doc = crate::effect_info("artistic.paintDaubs").unwrap().params;
|
||||
assert!(PAINT_DAUB_BRUSH_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
assert!(fx("artistic.paintDaubs", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
|
||||
for (id, key, table) in
|
||||
[("artistic.underpainting", "texture", TEXTURES.as_slice()), ("artistic.underpainting", "lightDirection", LIGHT_DIRECTIONS.as_slice())]
|
||||
{
|
||||
let doc = crate::effect_info(id).unwrap().params;
|
||||
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
||||
for (i, (_, v)) in table.iter().enumerate() {
|
||||
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
|
||||
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
|
||||
}
|
||||
}
|
||||
assert!(fx("artistic.roughPastels", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_pencil_strokes_change_pixels_and_keep_coverage() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = ramp(w, h);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.coloredPencil", json!({"strokePressure": 12})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_ne!(out, src);
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let mut mild = src.clone();
|
||||
fx("artistic.coloredPencil", json!({"strokePressure": 0, "paperBrightness": 0})).apply(&mut mild, w, h, &space(w, h));
|
||||
assert_eq!(mild, src, "zero pressure and paper leaves the art unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2399,9 +2617,21 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
let colour = [0.3, 0.65, 1.0];
|
||||
let filters = [
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 },
|
||||
PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
|
||||
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
|
||||
PixelFx::Underpainting {
|
||||
brush_size: 6.0,
|
||||
texture_coverage: 16.0,
|
||||
texture: Texture::Canvas,
|
||||
scaling: 1.0,
|
||||
relief: 4.0,
|
||||
light: Light::Top,
|
||||
invert: false,
|
||||
},
|
||||
PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false },
|
||||
];
|
||||
for f in &filters {
|
||||
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
|
||||
@@ -2423,6 +2653,27 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
|
||||
PixelFx::Underpainting {
|
||||
brush_size: f64::NAN,
|
||||
texture_coverage: 16.0,
|
||||
texture: Texture::Canvas,
|
||||
scaling: 1.0,
|
||||
relief: 4.0,
|
||||
light: Light::Top,
|
||||
invert: false,
|
||||
},
|
||||
PixelFx::Underpainting {
|
||||
brush_size: 6.0,
|
||||
texture_coverage: f64::INFINITY,
|
||||
texture: Texture::Canvas,
|
||||
scaling: 1.0,
|
||||
relief: 4.0,
|
||||
light: Light::Top,
|
||||
invert: false,
|
||||
},
|
||||
PixelFx::Fresco { brush_size: f64::NAN, brush_detail: 10.0, texture: 1.0 },
|
||||
PixelFx::Fresco { brush_size: 2.0, brush_detail: f64::INFINITY, texture: 1.0 },
|
||||
PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: f64::NEG_INFINITY },
|
||||
] {
|
||||
if run(&f, &space(w, h)) != src {
|
||||
changed.push(format!("{f:?}"));
|
||||
@@ -2431,6 +2682,43 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underpainting_softens_more_with_a_larger_brush_and_keeps_coverage() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = ramp(w, h);
|
||||
let diff = |brush: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.underpainting", json!({"brushSize": brush, "textureCoverage": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
out.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(src.as_chunks::<4>().0)
|
||||
.filter(|(_, s)| s[3] == 255)
|
||||
.map(|(o, s)| (0..3).map(|k| u32::from(o[k].abs_diff(s[k]))).sum::<u32>())
|
||||
.sum::<u32>()
|
||||
};
|
||||
assert!(diff(40.0) > diff(2.0) * 2, "large brush {} vs small {}", diff(40.0), diff(2.0));
|
||||
let mut out = src.clone();
|
||||
fx("artistic.underpainting", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresco_keeps_coverage_and_responds_to_texture() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = ramp(w, h);
|
||||
let mut low = src.clone();
|
||||
let mut high = src.clone();
|
||||
fx("artistic.fresco", json!({"texture": 1})).apply(&mut low, w, h, &space(w, h));
|
||||
fx("artistic.fresco", json!({"texture": 3})).apply(&mut high, w, h, &space(w, h));
|
||||
assert_ne!(low, src);
|
||||
assert_ne!(high, src);
|
||||
assert_ne!(low, high, "deeper texture changes the relief");
|
||||
for (a, b) in low.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3], "alpha is preserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() {
|
||||
let (w, h) = (48, 32);
|
||||
@@ -2465,6 +2753,20 @@ fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rough_pastels_keeps_coverage_and_longer_strokes_smear_more() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = ramp(w, h);
|
||||
let mut short = src.clone();
|
||||
let mut long = src.clone();
|
||||
fx("artistic.roughPastels", json!({"strokeLength": 8, "strokeDetail": 18, "relief": 12})).apply(&mut short, w, h, &space(w, h));
|
||||
fx("artistic.roughPastels", json!({"strokeLength": 32, "strokeDetail": 18, "relief": 12})).apply(&mut long, w, h, &space(w, h));
|
||||
assert!(short.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let changed = |a: &Vec<u8>, b: &Vec<u8>| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(o, s)| o != s).count();
|
||||
assert!(changed(&short, &src) > w * h / 8, "short strokes repaint many pixels");
|
||||
assert!(changed(&long, &short) > w * h / 16, "longer strokes smear differently");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_simplicity_smooths_the_pieces_edges() {
|
||||
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
|
||||
@@ -2478,6 +2780,28 @@ fn cutout_simplicity_smooths_the_pieces_edges() {
|
||||
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_daubs_coarsens_a_ramp_and_keeps_coverage() {
|
||||
let (w, h) = (64, 32);
|
||||
let src = ramp(w, h);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.paintDaubs", json!({"brushSize": 28, "sharpness": 35})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_ne!(out, src, "dabbed art differs from the ramp");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_daubs_larger_brushes_flatten_more() {
|
||||
let (w, h) = (80, 40);
|
||||
let src = ramp(w, h);
|
||||
let dab_colours = |size: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.paintDaubs", json!({"brushSize": size, "sharpness": 20})).apply(&mut out, w, h, &space(w, h));
|
||||
colours(&out).len()
|
||||
};
|
||||
assert!(dab_colours(35.0) < dab_colours(8.0), "{} vs {}", dab_colours(35.0), dab_colours(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
|
||||
let (w, h) = (40, 20);
|
||||
@@ -2512,6 +2836,24 @@ fn poster_edges_thickness_widens_the_lines() {
|
||||
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_brush_repaints_and_preserves_coverage() {
|
||||
let (w, h) = (48, 24);
|
||||
let src = ramp(w, h);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.dryBrush", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_ne!(out, src);
|
||||
for (a, b) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3], "coverage preserved");
|
||||
}
|
||||
let mut clear = vec![0u8; w * h * 4];
|
||||
let before = clear.clone();
|
||||
fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h));
|
||||
assert_eq!(clear, before);
|
||||
let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach();
|
||||
assert!(reach.is_some_and(|r| r > 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
|
||||
let (w, h) = (40, 20);
|
||||
@@ -2549,6 +2891,8 @@ fn artistic_filters_look_the_same_at_any_resolution() {
|
||||
("artistic.cutout", json!({"edgeSimplicity": 6})),
|
||||
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
|
||||
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
|
||||
("artistic.underpainting", json!({"brushSize": 12, "textureCoverage": 20, "relief": 10})),
|
||||
("artistic.roughPastels", json!({"strokeLength": 12, "strokeDetail": 15, "texture": "burlap", "relief": 10})),
|
||||
] {
|
||||
let (mut a, mut b) = (coarse.clone(), fine.clone());
|
||||
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
|
||||
@@ -2564,3 +2908,30 @@ fn artistic_filters_look_the_same_at_any_resolution() {
|
||||
assert!(diff < 12.0, "{id}: mean difference {diff}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_knife_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("artistic.paletteKnife", json!({})), PixelFx::PaletteKnife { stroke_size: 10.0, stroke_detail: 2.0, softness: 3.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.paletteKnife", json!({"strokeSize": 0, "strokeDetail": 0, "softness": 99})),
|
||||
PixelFx::PaletteKnife { stroke_size: 1.0, stroke_detail: 1.0, softness: 10.0 }
|
||||
);
|
||||
assert!(fx("artistic.paletteKnife", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_knife_smears_a_ramp_and_keeps_coverage() {
|
||||
let (w, h) = (48, 24);
|
||||
let src = ramp(w, h);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 1, "softness": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_ne!(out, src);
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let mut gentle = src.clone();
|
||||
fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 3, "softness": 0})).apply(&mut gentle, w, h, &space(w, h));
|
||||
let mean = |buf: &Vec<u8>| {
|
||||
let n = buf.len() / 4;
|
||||
buf.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).sum::<f64>() / n as f64
|
||||
};
|
||||
assert!((mean(&gentle) - mean(&src)).abs() < (mean(&out) - mean(&src)).abs());
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
//! whose slopes, taken in document units, are lit from one side.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::loaded::{LOADED, LoadedTexture, texture};
|
||||
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
@@ -65,13 +67,19 @@ impl Grain {
|
||||
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
|
||||
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
|
||||
|
||||
/// A Texturizer surface ([`TEXTURES`]), made in code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// Texturizer's Texture menu once a texture is loaded: [`TEXTURES`], then the loaded one.
|
||||
pub const TEXTURES_LOADED: [(&str, &str); 5] =
|
||||
[("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone"), ("Loaded Texture", LOADED)];
|
||||
|
||||
/// A Texturizer surface ([`TEXTURES`]), made in code or loaded from an image.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Texture {
|
||||
Brick,
|
||||
Burlap,
|
||||
Canvas,
|
||||
Sandstone,
|
||||
/// An image the user loaded ([`LoadedTexture`]).
|
||||
Loaded(Arc<LoadedTexture>),
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
@@ -82,13 +90,23 @@ impl Texture {
|
||||
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
|
||||
}
|
||||
|
||||
/// The texture of `value` with `stored` the effect's `textureImage`: the loaded texture for
|
||||
/// [`LOADED`] (any case) when `stored` holds one, else as [`Texture::parse`].
|
||||
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
|
||||
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
|
||||
Some(t) => Texture::Loaded(t),
|
||||
None => Self::parse(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
pub(super) fn height(self, p: Point) -> f64 {
|
||||
pub(super) fn height(&self, p: Point) -> f64 {
|
||||
match self {
|
||||
Texture::Brick => brick(p),
|
||||
Texture::Burlap => weave(p, 7.0, true),
|
||||
Texture::Canvas => weave(p, 4.0, false),
|
||||
Texture::Sandstone => sandstone(p),
|
||||
Texture::Loaded(t) => t.height(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,7 +574,7 @@ pub(super) fn texturizer(
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
texture: Texture,
|
||||
texture: &Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Use Preview Bounds: objects measured as they are drawn, with their geometry effects.
|
||||
//!
|
||||
//! [`drawn_art`] gives an object whose geometry effects change what it draws as plain art. A path
|
||||
//! or compound path is given as its effected geometry, and a fill or stroke with geometry effects
|
||||
//! of its own paints its own geometry. Type, images, live objects and symbol instances are given as
|
||||
//! their reshaped art ([`reshape`]), and a symbol instance is also given as its reshaped art when
|
||||
//! its symbol's art has geometry effects. Groups and layers with geometry or Pathfinder effects of
|
||||
//! their own, or with a fill or stroke of their own that has geometry effects or paints members
|
||||
//! with geometry effects, are given as their evaluated art ([`evaluate_container`]). Blends,
|
||||
//! envelopes and repeats whose objects have geometry effects are given as their evaluated art
|
||||
//! ([`expanded_live_group`]), with each of those objects given as its own art. A clip group or
|
||||
//! layer whose clipping path has strokes with geometry effects is given as that path's geometry
|
||||
//! before its effects, which the renderer clips by, and those strokes as drawn. Fills and
|
||||
//! strokes that paint nothing (hidden, or painted None) are left out. [`bounds_of`],
|
||||
//! [`oriented_bounds`] and [`anchor_bounds`] measure the visual bounds of that art, strokes
|
||||
//! included, where [`Document::bounds_of`], [`Document::oriented_bounds`] and
|
||||
//! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows,
|
||||
//! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured
|
||||
//! with its geometry effects, and the other instances in it are measured without them.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, OrientedBox, Selection, SymbolBoxes};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt};
|
||||
|
||||
use crate::group::has_own_paint;
|
||||
use crate::reshape::{geometry_effects, leaf_geometry};
|
||||
use crate::{
|
||||
GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline,
|
||||
outline_art, paints, reshape,
|
||||
};
|
||||
|
||||
/// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects:
|
||||
/// past it, the other instances in the object are measured without geometry effects.
|
||||
const MAX_INSTANCES: usize = 4096;
|
||||
|
||||
/// The bounds of `ids`: with `preview`, their visual bounds as drawn ([`visual_bounds`]), else
|
||||
/// their geometric bounds ([`Document::bounds_of`]).
|
||||
pub fn bounds_of(doc: &Document, ids: &[NodeId], preview: bool) -> Option<Rect> {
|
||||
if !preview {
|
||||
return doc.bounds_of(ids, false);
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
let drawn = Drawn::new(&symbols);
|
||||
ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().visual_bounds(n)))
|
||||
}
|
||||
|
||||
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]), measured as
|
||||
/// [`bounds_of`] measures them: [`Document::oriented_bounds`] with `preview` false.
|
||||
pub fn oriented_bounds(doc: &Document, ids: &[NodeId], preview: bool) -> Option<OrientedBox> {
|
||||
if !preview {
|
||||
return doc.oriented_bounds(ids, false);
|
||||
}
|
||||
let angle = doc.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return bounds_of(doc, ids, true).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let symbols = doc.symbol_boxes();
|
||||
let drawn = Drawn::new(&symbols);
|
||||
let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().bounds_in(n, to_local)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
|
||||
/// With anchors selected with Direct Selection, the box around those anchors and the objects
|
||||
/// selected whole, measured by [`bounds_of`]. `None` without any ([`Selection::anchor_bounds`]).
|
||||
pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Option<Rect> {
|
||||
if selection.anchors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc))
|
||||
}
|
||||
|
||||
/// The visual bounds of `n` as drawn: those of its [`drawn_art`], else
|
||||
/// [`Node::visual_bounds_with`].
|
||||
pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
Drawn::new(symbols).visual_bounds(n)
|
||||
}
|
||||
|
||||
/// [`visual_bounds`] of `n` as `a` would place it, without changing it ([`Node::bounds_in`]):
|
||||
/// the art is drawn where `n` is and then placed by `a`.
|
||||
pub fn bounds_in(symbols: &SymbolBoxes, n: &Node, a: Affine) -> Option<Rect> {
|
||||
Drawn::new(symbols).bounds_in(n, a)
|
||||
}
|
||||
|
||||
/// `n` as plain art with the geometry effects in it applied, at every depth, symbol instances
|
||||
/// measured by `symbols`. Raster effects stay on the art. A group, layer or intertwine without a
|
||||
/// clip is rebuilt with the drawn art of each member that has one. A clip group or layer becomes a
|
||||
/// group of its clipping path without strokes and the drawn art of the path's strokes, when they
|
||||
/// have one. `None` when no geometry effect in `n` changes what it draws.
|
||||
pub fn drawn_art(symbols: &SymbolBoxes, n: &Node) -> Option<Node> {
|
||||
Drawn::new(symbols).drawn_art(n)
|
||||
}
|
||||
|
||||
/// Whether [`drawn_art`] gives `n` art of its own, or would give one to an object in it. A symbol
|
||||
/// instance in `n` counts when its symbol's art has a geometry effect.
|
||||
pub fn draws_geometry(symbols: &SymbolBoxes, n: &Node) -> bool {
|
||||
Drawn::new(symbols).draws_geometry(n)
|
||||
}
|
||||
|
||||
/// One measurement: whether the art of each symbol checked so far draws geometry, by symbol name,
|
||||
/// and how many more instances' art it measures with its geometry effects in the current object ([`MAX_INSTANCES`]).
|
||||
struct Drawn<'s, 'd> {
|
||||
symbols: &'s SymbolBoxes<'d>,
|
||||
draws: RefCell<HashMap<String, bool>>,
|
||||
left: Cell<usize>,
|
||||
}
|
||||
|
||||
impl<'s, 'd> Drawn<'s, 'd> {
|
||||
fn new(symbols: &'s SymbolBoxes<'d>) -> Self {
|
||||
Drawn { symbols, draws: RefCell::default(), left: Cell::new(MAX_INSTANCES) }
|
||||
}
|
||||
|
||||
/// Sets the count of instances left back to [`MAX_INSTANCES`], for the next object.
|
||||
fn next(&self) -> &Self {
|
||||
self.left.set(MAX_INSTANCES);
|
||||
self
|
||||
}
|
||||
|
||||
/// See [`visual_bounds`].
|
||||
fn visual_bounds(&self, n: &Node) -> Option<Rect> {
|
||||
match self.drawn_art(n) {
|
||||
Some(art) => art.visual_bounds_with(self.symbols),
|
||||
None => n.visual_bounds_with(self.symbols),
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`bounds_in`].
|
||||
fn bounds_in(&self, n: &Node, a: Affine) -> Option<Rect> {
|
||||
if a == Affine::IDENTITY {
|
||||
return self.visual_bounds(n);
|
||||
}
|
||||
match self.drawn_art(n) {
|
||||
Some(art) => art.bounds_in(self.symbols, a, true),
|
||||
None => n.bounds_in(self.symbols, a, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`drawn_art`].
|
||||
fn drawn_art(&self, n: &Node) -> Option<Node> {
|
||||
let Some(mut art) = self.own_art(n) else { return self.clip_art(n).or_else(|| self.with_drawn_members(n)) };
|
||||
// The art's members can carry geometry effects of their own: type's fills and strokes, the
|
||||
// members of an evaluated group, the clipping path of an evaluated clip group and the
|
||||
// objects in a symbol's art.
|
||||
if let Some(m) = self.clip_art(&art).or_else(|| self.with_drawn_members(&art)) {
|
||||
art = m;
|
||||
}
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// See [`draws_geometry`].
|
||||
fn draws_geometry(&self, n: &Node) -> bool {
|
||||
let mut found = false;
|
||||
n.walk(&mut |c| {
|
||||
found = found
|
||||
|| match &c.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => !geometry_effects(c).is_empty() || self.symbol_draws(symbol),
|
||||
_ => reshapes(c),
|
||||
};
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// Whether the art of the symbol named `name` has effects and [`Self::draws_geometry`], worked
|
||||
/// out once for each name.
|
||||
fn symbol_draws(&self, name: &str) -> bool {
|
||||
if let Some(d) = self.draws.borrow().get(name) {
|
||||
return *d;
|
||||
}
|
||||
let d = self.symbols.has_effects(name) && self.symbols.measure_art(name, |art| self.draws_geometry(art)).unwrap_or(false);
|
||||
self.draws.borrow_mut().insert(name.to_string(), d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The art the geometry effects of `n` itself give it, or that a symbol instance gets from the
|
||||
/// geometry effects in its symbol's art. A group or layer with fills or strokes of its own that
|
||||
/// paint, around members with geometry effects, is evaluated too: its fills and strokes paint
|
||||
/// the members' geometry before their effects. A blend, envelope or repeat whose objects have
|
||||
/// geometry effects is evaluated as the renderer evaluates it ([`expanded_live_group`]), and
|
||||
/// the objects in its evaluated art keep their geometry effects. `None` when it has none, and
|
||||
/// for a symbol instance past [`MAX_INSTANCES`].
|
||||
fn own_art(&self, n: &Node) -> Option<Node> {
|
||||
let inner = || n.children().is_some_and(|ch| ch.iter().any(|c| self.draws_geometry(c)));
|
||||
let painted = || has_own_paint(n) && inner();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(n),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n),
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) {
|
||||
return None;
|
||||
}
|
||||
let left = self.left.get().checked_sub(1)?;
|
||||
self.left.set(left);
|
||||
let art = self.symbols.measure_art(symbol, Arc::clone)?;
|
||||
// An instance in perspective is projected by `reshape`.
|
||||
reshape(n, Some(&*art)).or_else(|| outline_art(n, Some(&*art)))
|
||||
}
|
||||
_ if reshapes(n) => reshape(n, None),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) if inner() => {
|
||||
Some(expanded_live_group(Some(self.symbols.document()), n))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`, a group, layer or intertwine without a clip, with each member that has a
|
||||
/// [`Self::drawn_art`] replaced by it. `None` for any other object and when no member has one.
|
||||
fn with_drawn_members(&self, n: &Node) -> Option<Node> {
|
||||
if n.clips() || !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Intertwine { .. }) {
|
||||
return None;
|
||||
}
|
||||
let drawn: Vec<(usize, Node)> = n.children()?.iter().enumerate().filter_map(|(i, c)| Some((i, self.drawn_art(c)?))).collect();
|
||||
if drawn.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut m = n.clone();
|
||||
let children = m.children_mut()?;
|
||||
for (i, c) in drawn {
|
||||
if let Some(slot) = children.get_mut(i) {
|
||||
*slot = Arc::new(c);
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Clip group or layer `n` as its clipping path's strokes draw it: a group of the clipping path
|
||||
/// without its strokes, measured as the region it clips to, and its strokes ([`Node::clip_paint`])
|
||||
/// as their [`Self::drawn_art`]. The renderer clips by the clipping path's geometry before its
|
||||
/// geometry effects ([`crate::clip_outline`]) and paints the path's fills inside that region and
|
||||
/// its strokes over the clipped art, unclipped. `None` for any other object, when the clipping
|
||||
/// path is hidden and when its strokes have no drawn art.
|
||||
fn clip_art(&self, n: &Node) -> Option<Node> {
|
||||
if !n.clips() {
|
||||
return None;
|
||||
}
|
||||
let clip = n.children()?.first().filter(|c| c.visible)?;
|
||||
let strokes = self.drawn_art(&clip.clip_paint().stroke?)?;
|
||||
Some(Node::group(n.id, vec![Arc::new(unstroked(clip)), Arc::new(strokes)]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipping path `clip` without its strokes or its characters' strokes.
|
||||
fn unstroked(clip: &Node) -> Node {
|
||||
let mut n = clip.clone();
|
||||
n.appearance.items.retain(AppearanceItem::is_fill);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
r.style.stroke = Paint::None;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Whether `n` itself has a geometry effect that [`Drawn::own_art`] applies, its own or one of its
|
||||
/// fill's or stroke's (a group or layer: also a Pathfinder effect; a symbol instance: only its own).
|
||||
fn reshapes(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => {
|
||||
has_pathfinder(n) || !geometry_effects(n).is_empty() || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects()))
|
||||
}
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
|
||||
has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects()))
|
||||
}
|
||||
_ => needs_outline(n) && (!geometry_effects(n).is_empty() || has_item_geometry(n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Path or compound path `n` as a group of paths: its geometry after its own geometry effects
|
||||
/// painted by its fills and strokes without geometry effects, and that geometry after the
|
||||
/// geometry effects of each other fill or stroke that paints, painted by that fill or stroke, as
|
||||
/// the renderer draws them. The first path is left out when only fills or strokes with geometry
|
||||
/// effects paint. The paths keep `n`'s id and fill rule and carry no effects.
|
||||
fn path_art(n: &Node) -> Option<Node> {
|
||||
let (path, rule) = leaf_geometry(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = apply_geometry_with(&n.appearance.effects, &path, path.to_bezpath().bounding_box(), &ctx);
|
||||
let piece = |geometry: PathData, items: Vec<AppearanceItem>| {
|
||||
let mut ap: Appearance = (*n.appearance).clone();
|
||||
ap.items = items;
|
||||
ap.effects.clear();
|
||||
for i in &mut ap.items {
|
||||
i.effects_mut().clear();
|
||||
}
|
||||
let mut p = Node::path(n.id, geometry, ap);
|
||||
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Arc::new(p)
|
||||
};
|
||||
let (own, plain): (Vec<&AppearanceItem>, Vec<&AppearanceItem>) = n.appearance.items.iter().partition(|i| paints(i) && has_geometry(i.effects()));
|
||||
let mut pieces = vec![];
|
||||
if own.is_empty() || plain.iter().any(|i| paints(i)) {
|
||||
pieces.push(piece(g.clone(), plain.into_iter().cloned().collect()));
|
||||
}
|
||||
let bounds = g.to_bezpath().bounding_box();
|
||||
for item in own {
|
||||
let geometry = apply_geometry_with(item.effects(), &g, bounds, &ctx.item(item));
|
||||
pieces.push(piece(geometry, vec![item.clone()]));
|
||||
}
|
||||
Some(Node::group(n.id, pieces))
|
||||
}
|
||||
@@ -143,6 +143,13 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
facets(id, &path, rule, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
|
||||
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
|
||||
/// [`validate_solid`].
|
||||
pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
|
||||
}
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"appearance.addFill",
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"appearance.addStroke",
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
@@ -218,7 +218,7 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
|
||||
Some(Value::Null) => Ok(ItemTarget::Top),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|i| ItemTarget::Item { index: i as usize, explicit: true })
|
||||
.map(|i| ItemTarget::Item { index: index(i), explicit: true })
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
|
||||
// The active item is a row of the first selected object's own stack.
|
||||
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
|
||||
@@ -383,8 +383,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -547,6 +548,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.setItem")?;
|
||||
super::paint::check_keep_model(p, "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
@@ -603,7 +605,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let mut v = match p.get("indices") {
|
||||
Some(a) => a
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::<Option<Vec<_>>>())
|
||||
.and_then(|a| a.iter().map(|x| x.as_u64().map(index)).collect::<Option<Vec<_>>>())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?,
|
||||
None => vec![index_param(p, "index", cmd)?],
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
//! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The
|
||||
//! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_brush::{self as brush, Bristle, Brush, BrushKind};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::swatch::names_param;
|
||||
use super::swatch::{anchor, names_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ----- brushes -----
|
||||
cmd!(query "brush.list", "Brushes", [], None, "{} → {brushes: [{name, type}], current}", has_doc, brush_list),
|
||||
cmd!(query "brush.get", "Brush Definition", [], None, "{name} → the brush definition (JSON)", has_doc, brush_get),
|
||||
cmd!(
|
||||
query "brush.selectionArt",
|
||||
"Brush Art",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {art: the object brush.new takes as a new scatter or art brush's art and as a new pattern brush's side tile (several objects become one group), or null when nothing is selected or ids name no object}",
|
||||
has_doc,
|
||||
brush_selection_art
|
||||
),
|
||||
cmd!(
|
||||
"brush.apply",
|
||||
"Apply Brush",
|
||||
@@ -62,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Brush…",
|
||||
[],
|
||||
None,
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush; a bristle brush name, with the number a taken name gets, has at most 31 characters), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
@@ -75,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!(
|
||||
"brush.move",
|
||||
"Move Brush",
|
||||
[],
|
||||
None,
|
||||
"{name? | names?: [brush names of one type, in order], to?: index among the brushes of that type in brush.list order (they go before the brush of that type now there that isn't moving; default: after the last one), type?: calligraphic|scatter|art|pattern|bristle (the type they go into; default: theirs)} reorder brushes within their type, as one undo step (none when the order stays as it was); brushes of different types, or a type that isn't theirs, are refused (brushes move only within their type) → {moved}",
|
||||
has_doc,
|
||||
brush_move
|
||||
),
|
||||
cmd!(
|
||||
"brush.duplicate",
|
||||
"Duplicate Brush",
|
||||
@@ -84,12 +103,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_duplicate
|
||||
),
|
||||
cmd!(
|
||||
query "brush.unused",
|
||||
"Select All Unused",
|
||||
[],
|
||||
None,
|
||||
"{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used",
|
||||
has_doc,
|
||||
brush_unused
|
||||
),
|
||||
cmd!(
|
||||
"brush.options",
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -268,6 +296,12 @@ fn brush_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(b).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]).
|
||||
fn brush_selection_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let art = selection_art(s, p)?;
|
||||
Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) }))
|
||||
}
|
||||
|
||||
fn brush_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "brush.apply";
|
||||
let name = name_param(p, C)?.to_string();
|
||||
@@ -348,38 +382,100 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The definition fields that `params` change field by field when the brush has them as objects:
|
||||
/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only
|
||||
/// `start` keeps `end`.
|
||||
const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"];
|
||||
|
||||
fn merge(base: &mut Value, patch: &Value) {
|
||||
if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) {
|
||||
for (k, v) in pt {
|
||||
b.insert(k.clone(), v.clone());
|
||||
match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) {
|
||||
(Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => {
|
||||
for (f, fv) in fields {
|
||||
inner.insert(f.clone(), fv.clone());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
b.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
|
||||
/// Sets brush `to` on the strokes in `items` that paint with brush `from`.
|
||||
fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) {
|
||||
for it in items {
|
||||
if let AppearanceItem::Stroke(st) = it
|
||||
&& st.brush.as_deref() == Some(from)
|
||||
{
|
||||
st.brush = to.map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`.
|
||||
fn paints_with(n: &Node, name: &str) -> bool {
|
||||
let mut hit = false;
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||||
hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name)));
|
||||
});
|
||||
hit
|
||||
}
|
||||
|
||||
/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that
|
||||
/// hold such a stroke, and those that hold them, are copied.
|
||||
fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) {
|
||||
set_stroke_brush(&mut n.appearance.items, from, to);
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& paints_with(&mask.art, from)
|
||||
{
|
||||
set_brush_in(Arc::make_mut(&mut mask.art), from, to);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if paints_with(c, from) {
|
||||
set_brush_in(Arc::make_mut(c), from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the art a brush is made of ([`Brush::art`]) for editing.
|
||||
fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> {
|
||||
use vectorcraft_brush::BrushKind;
|
||||
match &mut b.kind {
|
||||
BrushKind::Scatter(s) => vec![&mut s.art],
|
||||
BrushKind::Art(a) => vec![&mut a.art],
|
||||
BrushKind::Pattern(p) => {
|
||||
std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect()
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art
|
||||
/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern
|
||||
/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`.
|
||||
fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) {
|
||||
let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art));
|
||||
let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()));
|
||||
for n in trees.chain(patterns.flat_map(|p| &mut p.art)) {
|
||||
if paints_with(n, from) {
|
||||
set_brush_in(Arc::make_mut(n), from, to);
|
||||
}
|
||||
}
|
||||
for g in &mut d.graphic_styles {
|
||||
set_stroke_brush(&mut g.appearance.items, from, to);
|
||||
}
|
||||
for n in lib.iter_mut().flat_map(brush_art_mut) {
|
||||
set_brush_in(n, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_library(s: &mut Session, label: &str, mut lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
|
||||
s.edit(label, |d, _| {
|
||||
brush::store(d, &lib);
|
||||
for (from, to) in renames {
|
||||
let ids: Vec<NodeId> = {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) {
|
||||
v.push(n.id);
|
||||
}
|
||||
});
|
||||
v
|
||||
};
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
for it in &mut n.appearance.items {
|
||||
if let AppearanceItem::Stroke(st) = it
|
||||
&& st.brush.as_deref() == Some(from.as_str())
|
||||
{
|
||||
st.brush = to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rename_brush_strokes(d, &mut lib, from, to.as_deref());
|
||||
if brush::current(d).as_deref() == Some(from.as_str()) {
|
||||
match to {
|
||||
Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)),
|
||||
@@ -387,6 +483,7 @@ fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(Strin
|
||||
};
|
||||
}
|
||||
}
|
||||
brush::store(d, &lib);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -404,6 +501,9 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
|
||||
let name = brush::unique_name(&lib, &base_name);
|
||||
if ty == "bristle" {
|
||||
bristle_name_fits(C, &name)?;
|
||||
}
|
||||
let mut v = json!({"name": name, "type": ty});
|
||||
if let Some(params) = p.get("params") {
|
||||
merge(&mut v, params);
|
||||
@@ -424,11 +524,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// A brush definition from command params, its values kept within their ranges.
|
||||
/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters.
|
||||
fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> {
|
||||
let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS);
|
||||
if count > max {
|
||||
return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]).
|
||||
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
|
||||
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
|
||||
b.sanitize();
|
||||
Ok(b)
|
||||
Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
|
||||
}
|
||||
|
||||
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -444,6 +551,54 @@ fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their
|
||||
/// type in library order: before the first brush of that type at or after `to` that isn't moving,
|
||||
/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type`
|
||||
/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step.
|
||||
fn brush_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "brush.move";
|
||||
const WITHIN: &str = "brushes move only within their type";
|
||||
let names = names_param(p, C)?;
|
||||
check_numbers(p, &["to"], C)?;
|
||||
let to = match p.get("to").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?),
|
||||
None => None,
|
||||
};
|
||||
let old = brush::library(&s.doc()?.doc);
|
||||
let mut lib = old.clone();
|
||||
// `names_param` refuses an empty list, so the loop sets `kind`.
|
||||
let mut kind = "";
|
||||
for name in &names {
|
||||
let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
if !kind.is_empty() && kind != b.kind.type_id() {
|
||||
return Err(bad(C, WITHIN));
|
||||
}
|
||||
kind = b.kind.type_id();
|
||||
}
|
||||
if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) {
|
||||
return Err(bad(C, WITHIN));
|
||||
}
|
||||
let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect();
|
||||
let before = anchor(&same, to, &names, |b| &b.name);
|
||||
let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len());
|
||||
let mut items = vec![];
|
||||
for name in &names {
|
||||
if let Some(i) = lib.iter().position(|b| &b.name == name) {
|
||||
items.push(lib.remove(i));
|
||||
}
|
||||
}
|
||||
let at = match before {
|
||||
Some(a) => lib.iter().position(|b| b.name == a),
|
||||
None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1),
|
||||
};
|
||||
let at = at.unwrap_or(first).min(lib.len());
|
||||
lib.splice(at..at, items);
|
||||
if lib != old {
|
||||
save_library(s, "Move Brush", lib, &[])?;
|
||||
}
|
||||
Ok(json!({ "moved": names.len() }))
|
||||
}
|
||||
|
||||
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
|
||||
/// without `names`, names the copy.
|
||||
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -464,6 +619,44 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
|
||||
}
|
||||
|
||||
/// Adds the names of the brushes the strokes of `items` paint with to `out`.
|
||||
fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec<String>) {
|
||||
for it in items {
|
||||
if let AppearanceItem::Stroke(st) = it
|
||||
&& let Some(b) = &st.brush
|
||||
{
|
||||
out.push(b.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to
|
||||
/// `out`.
|
||||
fn art_brushes(n: &Node, out: &mut Vec<String>) {
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out));
|
||||
}
|
||||
|
||||
/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph
|
||||
/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush
|
||||
/// that a counted stroke paints with.
|
||||
fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let lib = brush::library(d);
|
||||
let mut todo = vec![];
|
||||
let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art));
|
||||
trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo));
|
||||
d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo));
|
||||
let mut used = BTreeSet::new();
|
||||
while let Some(name) = todo.pop() {
|
||||
if !used.contains(&name) {
|
||||
lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo));
|
||||
used.insert(name);
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect();
|
||||
Ok(json!({ "names": names }))
|
||||
}
|
||||
|
||||
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "brush.options";
|
||||
let name = name_param(p, C)?.to_string();
|
||||
@@ -478,6 +671,10 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if lib.iter().any(|b| &b.name == nn) {
|
||||
return Err(bad(C, format!("a brush named `{nn}` exists")));
|
||||
}
|
||||
// A `newName` that only trims the white space around the saved name is taken at any length.
|
||||
if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() {
|
||||
bristle_name_fits(C, nn)?;
|
||||
}
|
||||
v["name"] = json!(nn);
|
||||
} else {
|
||||
v["name"] = json!(name);
|
||||
@@ -1104,6 +1301,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) {
|
||||
return Err(bad(C, format!("unknown tool `{tool}`")));
|
||||
}
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let pts = points(p, C)?;
|
||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
||||
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
|
||||
@@ -1133,7 +1331,8 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
v.retain(|id| d.is_editable(*id));
|
||||
let editable = d.editable();
|
||||
v.retain(|id| editable(*id));
|
||||
v
|
||||
};
|
||||
let label = match tool.as_str() {
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder Regions",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
|
||||
"{ids?|id?, openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
|
||||
has_selection_or_ids,
|
||||
sb_regions
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -245,12 +245,24 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let erase = bool_or(p, "erase", false);
|
||||
// Line pieces and edges are only deleted (erase mode), never merged.
|
||||
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let fill = fill_param(s, p, C)?;
|
||||
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
|
||||
let swatches = match p.get("pickColorFrom") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("artwork") => false,
|
||||
Some("swatches") => true,
|
||||
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
|
||||
},
|
||||
};
|
||||
let current_fill = s.paint.fill.clone();
|
||||
let fill = match fill_param(s, p, C)? {
|
||||
None if swatches => Some(current_fill.clone()),
|
||||
f => f,
|
||||
};
|
||||
let roots = root_ids(s, p)?;
|
||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||
let (ids, merged, touched) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, erase);
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, erase, open_filled_as_closed);
|
||||
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
||||
if map.shapes.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||
@@ -311,7 +323,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||
};
|
||||
let fill_closes = !leaf.appearance.fill_paint().is_none();
|
||||
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
|
||||
let mut cut = false;
|
||||
for piece in &cuts {
|
||||
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
||||
@@ -366,7 +378,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, true);
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, true, bool_or(p, "openFilledAsClosed", true));
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
@@ -501,8 +513,12 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
|
||||
/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them
|
||||
/// as Merge does; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) {
|
||||
return merge_live(d, sel, roots);
|
||||
}
|
||||
let mut sources = vec![];
|
||||
for r in roots {
|
||||
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
@@ -523,71 +539,78 @@ fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
|
||||
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
|
||||
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
|
||||
/// are merged; returns (group, faces, edges).
|
||||
fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
let target = roots
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|r| d.node(*r).is_some_and(is_live_paint))
|
||||
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
|
||||
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
|
||||
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
|
||||
.iter()
|
||||
.filter(|f| !f.appearance.fill_paint().is_none())
|
||||
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
|
||||
.collect();
|
||||
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let pd = e.path_data()?;
|
||||
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
|
||||
})
|
||||
.collect();
|
||||
let mut sources = source_nodes(&old);
|
||||
for r in roots {
|
||||
if *r != target {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
// The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic
|
||||
// style link and the rest; its children are made again and its Gap Options set to the merged ones.
|
||||
let mut g = old;
|
||||
if let Some(ch) = g.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
|
||||
{
|
||||
Arc::make_mut(f).appearance.set_fill(paint);
|
||||
}
|
||||
}
|
||||
for (pt, app) in keep_edges {
|
||||
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
|
||||
{
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
}
|
||||
|
||||
fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| {
|
||||
let target = roots
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|r| d.node(*r).is_some_and(is_live_paint))
|
||||
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
|
||||
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
|
||||
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
|
||||
.iter()
|
||||
.filter(|f| !f.appearance.fill_paint().is_none())
|
||||
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
|
||||
.collect();
|
||||
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let pd = e.path_data()?;
|
||||
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
|
||||
})
|
||||
.collect();
|
||||
let mut sources = source_nodes(&old);
|
||||
for r in &roots {
|
||||
if *r != target {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
let mut g = Node::group(target, children);
|
||||
g.name = old.name.clone();
|
||||
g.opacity = old.opacity;
|
||||
g.blend = old.blend;
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
|
||||
{
|
||||
Arc::make_mut(f).appearance.set_fill(paint);
|
||||
}
|
||||
}
|
||||
for (pt, app) in keep_edges {
|
||||
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
|
||||
{
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, &roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
})?;
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
|
||||
@@ -662,6 +685,12 @@ fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
|
||||
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
|
||||
}
|
||||
|
||||
/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide,
|
||||
/// as `livePaint.info` lists them. None when `group` is not a Live Paint group.
|
||||
pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> {
|
||||
if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] }
|
||||
}
|
||||
|
||||
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.gapOptions";
|
||||
let detect = match p.get("detect").filter(|v| !v.is_null()) {
|
||||
@@ -723,6 +752,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.fill";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = id_param(p, "group");
|
||||
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
|
||||
@@ -752,6 +782,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.strokeEdge";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
|
||||
if !(0.0..=1000.0).contains(&width) {
|
||||
@@ -760,7 +791,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
|
||||
e.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = e.appearance.stroke_mut() {
|
||||
@@ -881,13 +912,14 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use; a .ai saved without PDF compatibility: every object of its editing data, as document.open reads it, with or without editingData), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
@@ -441,6 +441,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
|
||||
t.system_layout = s.doc()?.doc.setup.system_layout;
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
|
||||
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
|
||||
@@ -510,7 +511,15 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (mut doc, warnings) = match fileio::page_document(&bytes, page - 1, &opts) {
|
||||
// For a .ai saved without PDF compatibility, the clipboard takes every object of its
|
||||
// editing data, as document.open reads it.
|
||||
Err(e) if fileio::is_placeholder(&e) => {
|
||||
let loaded = fileio::load_with("Clipboard.ai", &bytes, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
read => read.map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
|
||||
@@ -214,6 +214,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
h: r.height(),
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: rect_transform(r, p, "shape.rectangle")?,
|
||||
};
|
||||
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
|
||||
@@ -232,7 +233,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] };
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
|
||||
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
|
||||
}
|
||||
|
||||
@@ -493,6 +494,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let x = f64_req(p, "x", "text.create")?;
|
||||
let y = f64_req(p, "y", "text.create")?;
|
||||
super::paint::check_keep_model(p, "text.create")?;
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -504,6 +506,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
t.area.fit = new_area_fit(s, a, "text.create")?;
|
||||
}
|
||||
t.system_layout = s.doc()?.doc.setup.system_layout;
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
|
||||
@@ -130,7 +130,8 @@ fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out.retain(|id| doc.is_editable(*id));
|
||||
let editable = doc.editable();
|
||||
out.retain(|id| editable(*id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,11 +165,13 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let o = dimension_of(&st.doc, from)?.options;
|
||||
let mut ids = vec![];
|
||||
let editable = st.doc.editable();
|
||||
st.doc.walk(|n| {
|
||||
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
|
||||
if n.extras().dimension.is_some() && editable(n.id) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
drop(editable);
|
||||
s.edit("Apply to All", |doc, _| {
|
||||
for id in &ids {
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
@@ -351,6 +353,7 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
};
|
||||
let mut text = TextObject::point(Point::ZERO, &label, style);
|
||||
text.para.justify = vectorcraft_doc::Justify::Center;
|
||||
text.system_layout = doc.setup.system_layout;
|
||||
super::typecmd::refresh_bounds(&mut text);
|
||||
// Centre the label's box on its anchor point, along the line.
|
||||
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Liquify",
|
||||
[],
|
||||
None,
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
has_doc,
|
||||
liquify
|
||||
),
|
||||
@@ -300,7 +300,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
|
||||
return Err(bad(C, "left/right must be non-negative widths"));
|
||||
}
|
||||
let adjust = bool_or(p, "adjustAdjoining", false);
|
||||
let adjust = switch_or(p, "adjustAdjoining", false, C)?;
|
||||
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
|
||||
// history stay as they are.
|
||||
let mut pos = 0;
|
||||
@@ -405,7 +405,7 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Array(a)) => {
|
||||
let mut pts = vec![];
|
||||
for q in a {
|
||||
let v: Vec<f64> = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let v: Vec<f64> = q.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()).unwrap_or_default();
|
||||
if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 {
|
||||
return Err(bad(C, "points must be [t, left, right] with non-negative widths"));
|
||||
}
|
||||
@@ -455,7 +455,8 @@ fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
|
||||
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
|
||||
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
|
||||
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
|
||||
/// and what it is. Hidden and locked objects, and guides, are neither.
|
||||
/// and what it is. Hidden and locked objects, the art on a locked artboard, and guides are
|
||||
/// neither.
|
||||
#[derive(Default)]
|
||||
struct Targets {
|
||||
paths: Vec<(NodeId, Rect)>,
|
||||
@@ -484,19 +485,21 @@ impl Targets {
|
||||
/// The targets under `roots` (the selection), or with none every visible object.
|
||||
fn of(d: &Document, roots: &[NodeId]) -> Self {
|
||||
let mut t = Self::default();
|
||||
let locks = d.locked_artboards();
|
||||
if roots.is_empty() {
|
||||
d.layers.iter().for_each(|l| t.visit(l));
|
||||
d.layers.iter().for_each(|l| t.visit(l, &locks));
|
||||
return t;
|
||||
}
|
||||
let editable = |id| d.is_editable_with(&locks, id);
|
||||
for r in roots {
|
||||
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
|
||||
if !d.is_editable(*r) {
|
||||
if !editable(*r) {
|
||||
continue;
|
||||
}
|
||||
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
|
||||
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
|
||||
Some((a, what)) => t.block(a, what),
|
||||
None => t.visit(n),
|
||||
None => t.visit(n, &locks),
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -510,7 +513,7 @@ impl Targets {
|
||||
}
|
||||
}
|
||||
|
||||
fn visit(&mut self, n: &Node) {
|
||||
fn visit(&mut self, n: &Node, locks: &vectorcraft_doc::LockedArtboards) {
|
||||
if !n.visible || n.locked {
|
||||
return;
|
||||
}
|
||||
@@ -525,7 +528,8 @@ impl Targets {
|
||||
self.paths.push((n.id, b));
|
||||
}
|
||||
}
|
||||
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
|
||||
// The objects of a layer on a locked artboard, members and all, are left out.
|
||||
_ => n.children().into_iter().flatten().filter(|c| !(n.is_layer() && locks.holds(c))).for_each(|c| self.visit(c, locks)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,9 +632,17 @@ impl LiquifyStroke {
|
||||
}
|
||||
}
|
||||
|
||||
/// The number keys of `object.liquify`. [`LiquifyParams::from_json`] reads a value that is not a
|
||||
/// number as the key left out.
|
||||
const LIQUIFY_NUMBERS: &[&str] =
|
||||
&["diameter", "width", "height", "angle", "intensity", "detail", "simplify", "rate", "complexity", "horizontal", "vertical"];
|
||||
|
||||
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.liquify";
|
||||
check_numbers(p, LIQUIFY_NUMBERS, C)?;
|
||||
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
|
||||
// `LiquifyParams::from_json` uses a key's default for a value that is not true or false.
|
||||
check_switches(p, &["usePressure", "simplifyOn", "affectAnchors", "affectIn", "affectOut"], C)?;
|
||||
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
|
||||
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
|
||||
return Err(bad(C, "points must be finite"));
|
||||
@@ -697,9 +709,10 @@ fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Resul
|
||||
|
||||
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.puppetWarp";
|
||||
check_numbers(p, &["expand"], C)?;
|
||||
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
|
||||
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
|
||||
let rest = bool_or(p, "rest", false);
|
||||
let rest = switch_or(p, "rest", false, C)?;
|
||||
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
|
||||
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
|
||||
}
|
||||
@@ -711,7 +724,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||||
let angles: Vec<Option<f64>> = match p.get("angles") {
|
||||
None | Some(Value::Null) => vec![None; pins.len()],
|
||||
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
|
||||
Some(Value::Array(a)) if a.len() == pins.len() && a.iter().all(|v| v.is_null() || v.is_number()) => {
|
||||
a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect()
|
||||
}
|
||||
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
|
||||
};
|
||||
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||||
@@ -752,6 +767,7 @@ pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||||
}
|
||||
|
||||
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["expand"], "object.puppetWarp.pins")?;
|
||||
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
|
||||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||||
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
|
||||
@@ -794,9 +810,10 @@ pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> R
|
||||
}
|
||||
|
||||
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.grid.set";
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
|
||||
let reproject = bool_or(p, "reproject", false);
|
||||
let g = old.merged(p).map_err(|e| bad(C, e))?;
|
||||
let reproject = switch_or(p, "reproject", false, C)?;
|
||||
// When the document holds this grid already, no object moves, and the document and its undo
|
||||
// history stay as they are.
|
||||
if grid_kept(&s.doc()?.doc, &g) {
|
||||
@@ -819,7 +836,12 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = p.get("visible").and_then(Value::as_bool);
|
||||
const C: &str = "perspective.grid.show";
|
||||
// A missing or null `visible` toggles the grid.
|
||||
let want = match p.get("visible") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(switch_or(p, "visible", false, C)?),
|
||||
};
|
||||
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
|
||||
Ok(json!({ "visible": g.visible }))
|
||||
}
|
||||
@@ -1033,7 +1055,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "from and to must be finite"));
|
||||
}
|
||||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||||
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
|
||||
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Snap to Grid: a correction for the objects on the first one's plane and depth.
|
||||
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
|
||||
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
|
||||
@@ -1084,13 +1108,13 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.transform";
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let dz = f64_or(p, "depth", 0.0);
|
||||
let dz = number_or(p, "depth", 0.0, C)?;
|
||||
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
|
||||
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
|
||||
}
|
||||
let ids = roots(s, p)?;
|
||||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
|
||||
let to = depth + dz;
|
||||
if to.abs() > persp::MAX_DEPTH {
|
||||
@@ -1122,6 +1146,7 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
|
||||
|
||||
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.plane.move";
|
||||
check_numbers(p, &["offset", "by"], C)?;
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&old, p, C)?;
|
||||
let from = old.offset(plane);
|
||||
@@ -1144,9 +1169,10 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// The objects on the plane where it was (not those moved off it along its normal).
|
||||
let on_plane = |d: &Document| -> Vec<NodeId> {
|
||||
let editable = d.editable();
|
||||
persp::attached_roots(d)
|
||||
.into_iter()
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id))
|
||||
.map(|(id, ..)| id)
|
||||
.collect()
|
||||
};
|
||||
@@ -1218,11 +1244,12 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.nudge";
|
||||
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
|
||||
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
|
||||
return Err(bad(C, "dx and dy give the arrow's direction"));
|
||||
}
|
||||
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
|
||||
let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 };
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
let ids = selected_roots(s)?;
|
||||
// The step is measured on the page at the centre of the selection's perspective box.
|
||||
let st = s.doc()?;
|
||||
@@ -1230,10 +1257,7 @@ fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
|
||||
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
|
||||
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
|
||||
persp_move(
|
||||
s,
|
||||
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
|
||||
)
|
||||
persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy}))
|
||||
}
|
||||
|
||||
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1242,11 +1266,14 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&g, p, C)?;
|
||||
let snap = bool_or(p, "snap", g.snap);
|
||||
let snap = switch_or(p, "snap", g.snap, C)?;
|
||||
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
|
||||
let map = match point_param(p, "at") {
|
||||
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
|
||||
None => None,
|
||||
let map = match p.get("at") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?)
|
||||
}
|
||||
};
|
||||
let undo_before = s.doc()?.history.undo.len();
|
||||
let r = s.execute(&command, ¶ms)?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind, RepeatKind, RepeatSpec};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_text::embed::Embedding;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -35,10 +35,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Document Info's categories: (id, title), in the order the panel and the report show them.
|
||||
pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
pub const CATEGORIES: [(&str, &str); 13] = [
|
||||
("document", "Document"),
|
||||
("objects", "Objects"),
|
||||
("graphicStyles", "Graphic Styles"),
|
||||
("brushes", "Brushes"),
|
||||
("spotColors", "Spot Colors"),
|
||||
("patterns", "Pattern Objects"),
|
||||
("gradients", "Gradient Swatches"),
|
||||
@@ -47,6 +48,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
("fontDetails", "Font Details"),
|
||||
("linkedImages", "Linked Images"),
|
||||
("embeddedImages", "Embedded Images"),
|
||||
("repeats", "Repeat Objects"),
|
||||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
@@ -96,9 +98,47 @@ pub fn embedding_label(fs_type: u16) -> &'static str {
|
||||
/// An image object as Document Info lists it: (name, width, height, linked file).
|
||||
type ImageRow = (String, u32, u32, Option<String>);
|
||||
|
||||
/// A Repeat object as Document Info lists it: its `repeatObjects` entry, and its row, which gives
|
||||
/// its name and then its kind with the number of instances it draws (for a grid, its rows and
|
||||
/// columns, followed by the number it draws when that is fewer).
|
||||
fn repeat_entry(n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) {
|
||||
let name = n.display_name();
|
||||
let count = |n: usize, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") };
|
||||
let instances = spec.instances();
|
||||
let mut entry = json!({ "id": n.id.0, "name": name, "instances": instances });
|
||||
let kind = &spec.kind;
|
||||
let detail = match kind {
|
||||
RepeatKind::Radial { .. } => {
|
||||
entry["type"] = json!("radial");
|
||||
count(instances, "instance", "instances")
|
||||
}
|
||||
RepeatKind::Mirror { .. } => {
|
||||
entry["type"] = json!("mirror");
|
||||
count(instances, "instance", "instances")
|
||||
}
|
||||
RepeatKind::Grid { rows, cols, .. } => {
|
||||
// `repeat_transforms` clamps the rows and columns the same way.
|
||||
let (rows, cols) = ((*rows).clamp(1, 500), (*cols).clamp(1, 500));
|
||||
entry["type"] = json!("grid");
|
||||
entry["rows"] = json!(rows);
|
||||
entry["cols"] = json!(cols);
|
||||
let grid = format!("{} × {}", count(rows as usize, "row", "rows"), count(cols as usize, "column", "columns"));
|
||||
if instances < (rows * cols) as usize { format!("{grid}, {}", count(instances, "instance", "instances")) } else { grid }
|
||||
}
|
||||
};
|
||||
(entry, (name, format!("{}, {detail}", kind.label())))
|
||||
}
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects.
|
||||
fn sections(
|
||||
d: &Document,
|
||||
info: &Value,
|
||||
fonts: &BTreeSet<super::fonts::UsedFont>,
|
||||
images: &[ImageRow],
|
||||
repeats: &[(String, String)],
|
||||
category: Option<&str>,
|
||||
) -> Vec<Section> {
|
||||
let names = |k: &str| -> Vec<(String, String)> {
|
||||
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
|
||||
};
|
||||
@@ -138,6 +178,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
|
||||
rows
|
||||
}
|
||||
"graphicStyles" => names("graphicStyleNames"),
|
||||
"brushes" => names("brushNames"),
|
||||
"spotColors" => names("spotColors"),
|
||||
"patterns" => names("patternNames"),
|
||||
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
|
||||
@@ -174,6 +215,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
|
||||
"linkedImages" => {
|
||||
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
|
||||
}
|
||||
"repeats" => repeats.to_vec(),
|
||||
_ => images.iter().filter(|i| i.3.is_none()).map(|(name, w, h, _)| (name.clone(), format!("{w} × {h} px"))).collect(),
|
||||
};
|
||||
Section { id, title, rows }
|
||||
@@ -246,6 +288,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut symbols = BTreeSet::new();
|
||||
let mut spot = BTreeSet::new();
|
||||
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
|
||||
// `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use.
|
||||
let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() };
|
||||
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
|
||||
let mut repeats: Vec<Value> = vec![];
|
||||
let mut repeat_rows: Vec<(String, String)> = vec![];
|
||||
for r in roots {
|
||||
r.walk(&mut |n| {
|
||||
let k = match &n.kind {
|
||||
@@ -319,6 +366,21 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
styles.push(&g.name);
|
||||
}
|
||||
if selection_only {
|
||||
for item in &n.appearance.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item
|
||||
&& let Some(b) = &st.brush
|
||||
&& !brushes.contains(b)
|
||||
{
|
||||
brushes.push(b.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let NodeKind::Repeat(spec) = &n.kind {
|
||||
let (entry, row) = repeat_entry(n, spec);
|
||||
repeats.push(entry);
|
||||
repeat_rows.push(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
@@ -333,6 +395,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"swatches": d.swatches_iter().count(),
|
||||
"graphicStyles": d.graphic_styles.len(),
|
||||
"graphicStyleNames": styles,
|
||||
"brushNames": brushes,
|
||||
"repeatObjects": repeats,
|
||||
"characterStyles": d.char_styles.len(),
|
||||
"paragraphStyles": d.para_styles.len(),
|
||||
"patterns": d.patterns.len(),
|
||||
@@ -340,7 +404,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, category);
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
@@ -448,6 +512,109 @@ mod tests {
|
||||
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
|
||||
}
|
||||
|
||||
/// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists
|
||||
/// the brushes the selected objects' strokes use, each once.
|
||||
#[test]
|
||||
fn info_lists_the_brushes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let panel: Vec<Value> =
|
||||
s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect();
|
||||
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
|
||||
assert_eq!(i["brushNames"], json!(panel));
|
||||
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes")));
|
||||
let rows: Vec<Value> = panel.iter().map(|n| json!([n, ""])).collect();
|
||||
assert_eq!(i["sections"][0]["rows"], json!(rows));
|
||||
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap();
|
||||
let names =
|
||||
|s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"]));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Arrow"]));
|
||||
// A stroke without a brush lists none.
|
||||
let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [plain]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!([]));
|
||||
let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}");
|
||||
assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}");
|
||||
}
|
||||
|
||||
/// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number
|
||||
/// of instances it makes (for a grid, its rows and columns, followed by the number it draws when
|
||||
/// that is fewer), one in a group included. With Selection Only it lists the Repeat objects in
|
||||
/// the selection.
|
||||
#[test]
|
||||
fn info_lists_the_repeat_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let square =
|
||||
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let (a, b, c, d) = (square(&mut s, 0.0), square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0));
|
||||
let radial = s.execute("object.repeat.radial", &json!({"ids": [a], "instances": 6})).unwrap()["id"].clone();
|
||||
let grid = s.execute("object.repeat.grid", &json!({"ids": [b], "rows": 2, "cols": 1})).unwrap()["id"].clone();
|
||||
s.execute("layer.setProps", &json!({"id": grid, "name": "Tiles"})).unwrap();
|
||||
let mirror = s.execute("object.repeat.mirror", &json!({"ids": [c]})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [mirror, d]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||||
let i = s.execute("document.info", &json!({"category": "repeats"})).unwrap();
|
||||
assert_eq!(
|
||||
i["repeatObjects"],
|
||||
json!([
|
||||
{"id": radial, "name": "<Radial Repeat>", "type": "radial", "instances": 6},
|
||||
{"id": grid, "name": "Tiles", "type": "grid", "rows": 2, "cols": 1, "instances": 2},
|
||||
{"id": mirror, "name": "<Mirror Repeat>", "type": "mirror", "instances": 2},
|
||||
])
|
||||
);
|
||||
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("repeats"), Some("Repeat Objects")));
|
||||
assert_eq!(
|
||||
i["sections"][0]["rows"],
|
||||
json!([
|
||||
["<Radial Repeat>", "Radial Repeat, 6 instances"],
|
||||
["Tiles", "Grid Repeat, 2 rows × 1 column"],
|
||||
["<Mirror Repeat>", "Mirror Repeat, 2 instances"],
|
||||
])
|
||||
);
|
||||
let names = |s: &mut Session| -> Vec<Value> {
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
|
||||
i["repeatObjects"].as_array().unwrap().iter().map(|r| r["name"].clone()).collect()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [grid, group]})).unwrap();
|
||||
assert_eq!(names(&mut s), ["Tiles", "<Mirror Repeat>"]);
|
||||
s.execute("select.set", &json!({"ids": [radial]})).unwrap();
|
||||
assert_eq!(names(&mut s), ["<Radial Repeat>"]);
|
||||
s.execute("select.set", &json!({"ids": [d]})).unwrap();
|
||||
assert!(names(&mut s).is_empty());
|
||||
let text = s.execute("document.info", &json!({"category": "repeats", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nREPEAT OBJECTS\n<Radial Repeat>: Radial Repeat, 6 instances\nTiles: Grid Repeat, 2 rows × 1 column\n"), "{text}");
|
||||
assert!(!text.contains("\nOBJECTS\n"), "{text}");
|
||||
// A radial of 1,000 copies of a 500 × 500 grid draws one copy within the instance budget,
|
||||
// and Document Info reports 1 instance.
|
||||
let e = square(&mut s, 350.0);
|
||||
let big = s.execute("object.repeat.grid", &json!({"ids": [e], "rows": 500, "cols": 500})).unwrap()["id"].clone();
|
||||
let outer = s.execute("object.repeat.radial", &json!({"ids": [big], "instances": 1000})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [outer]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
|
||||
assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (outer, json!(1)));
|
||||
assert_eq!((i["repeatObjects"][1]["id"].clone(), i["repeatObjects"][1]["instances"].clone()), (big, json!(250_000)));
|
||||
assert_eq!(i["sections"][0]["rows"][0], json!(["<Radial Repeat>", "Radial Repeat, 1 instance"]));
|
||||
// A 500 × 500 grid of a mirror draws 125,000 copies within the budget, and its row gives
|
||||
// that number after its rows and columns.
|
||||
let f = square(&mut s, 380.0);
|
||||
let pair = s.execute("object.repeat.mirror", &json!({"ids": [f]})).unwrap()["id"].clone();
|
||||
let tiles = s.execute("object.repeat.grid", &json!({"ids": [pair], "rows": 500, "cols": 500})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [tiles]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
|
||||
assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (tiles, json!(125_000)));
|
||||
assert_eq!(i["sections"][0]["rows"][0], json!(["<Grid Repeat>", "Grid Repeat, 500 rows × 500 columns, 125000 instances"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_permissions_read_from_fs_type() {
|
||||
assert_eq!(embedding_label(0), "embedding allowed");
|
||||
|
||||
@@ -83,13 +83,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Bristle Brush Strokes",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} objects whose brush name contains \"bristle\" → {count}",
|
||||
"{} objects whose topmost stroke has a bristle brush (type bristle in brush.list), whatever the brush is named → {count}",
|
||||
has_doc,
|
||||
|s, _| by_kind(s, |n| n
|
||||
.appearance
|
||||
.stroke()
|
||||
.and_then(|st| st.brush.as_deref())
|
||||
.is_some_and(|b| b.to_ascii_lowercase().contains("bristle")))
|
||||
|s, _| {
|
||||
let brushes = vectorcraft_brush::library(&s.doc()?.doc);
|
||||
by_kind(s, |n| {
|
||||
n.appearance
|
||||
.stroke()
|
||||
.and_then(|st| st.brush.as_deref())
|
||||
.and_then(|name| brushes.iter().find(|b| b.name == name))
|
||||
.is_some_and(|b| matches!(b.kind, vectorcraft_brush::BrushKind::Bristle(_)))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"select.save",
|
||||
@@ -305,7 +310,7 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
|
||||
finish_same(s, cmd, ids)
|
||||
}
|
||||
|
||||
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
|
||||
fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result<Value> {
|
||||
let ids = candidates(s, f)?;
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
@@ -538,10 +543,11 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
let mut paths = vec![];
|
||||
let editable = d.editable();
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(r) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && editable(c.id) && !paths.contains(&c.id) {
|
||||
paths.push(c.id);
|
||||
}
|
||||
});
|
||||
@@ -557,7 +563,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
};
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
|
||||
let editable = st.doc.editable();
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) }
|
||||
};
|
||||
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -152,24 +152,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
|
||||
/// `[[x,y]…]` or `[{x,y}…]`.
|
||||
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
|
||||
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
|
||||
}
|
||||
|
||||
/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite
|
||||
/// number.
|
||||
fn point_entry(v: &Value, cmd: &str) -> Result<Point> {
|
||||
let xy = match v {
|
||||
Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)),
|
||||
Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)),
|
||||
_ => None,
|
||||
};
|
||||
xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers"))
|
||||
}
|
||||
|
||||
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
|
||||
/// `[{x,y,pressure}…]` (0..1).
|
||||
/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a
|
||||
/// number nor null, is an error.
|
||||
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
|
||||
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
|
||||
let pts: Vec<(Point, Option<f64>)> = a
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
|
||||
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
|
||||
_ => None,
|
||||
.map(|v| {
|
||||
let pressure = match v {
|
||||
Value::Array(xy) => xy.get(2),
|
||||
_ => v.get("pressure"),
|
||||
};
|
||||
match pressure {
|
||||
None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))),
|
||||
Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))),
|
||||
}
|
||||
})
|
||||
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
|
||||
.collect();
|
||||
.collect::<Result<_>>()?;
|
||||
if pts.is_empty() {
|
||||
return Err(bad(cmd, "need at least one point"));
|
||||
}
|
||||
@@ -211,8 +227,9 @@ fn within_input_bound(q: Point) -> bool {
|
||||
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
@@ -293,7 +310,8 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.retain(|id| doc.is_editable(*id));
|
||||
let editable = doc.editable();
|
||||
out.retain(|id| editable(*id));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -646,7 +664,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
let keep_selected = bool_or(p, "keepSelected", true);
|
||||
@@ -722,7 +740,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
let within = number_or(rd, "within", 12.0, C)?;
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
@@ -762,16 +780,8 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.curvature";
|
||||
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
|
||||
let pts: Vec<(Point, bool)> = arr
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let pt = match v {
|
||||
Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?),
|
||||
_ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
|
||||
};
|
||||
(pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))
|
||||
})
|
||||
.collect();
|
||||
let pts: Vec<(Point, bool)> =
|
||||
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
@@ -805,6 +815,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
check_numbers(p, &["x", "y", "t"], C)?;
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -978,9 +989,11 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
check_numbers(p, &["x", "y"], C)?;
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) => Some(Point::new(x, y)),
|
||||
_ => None,
|
||||
(None, None) => None,
|
||||
_ => return Err(bad(C, "x and y must be given together")),
|
||||
};
|
||||
if target.is_some_and(|q| !within_input_bound(q)) {
|
||||
return Err(bad(C, "x and y must be finite"));
|
||||
@@ -1012,8 +1025,8 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
|
||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
@@ -1130,7 +1143,7 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.eraseRegion";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let size = f64_or(p, "size", 10.0);
|
||||
let size = number_or(p, "size", 10.0, C)?;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
@@ -1172,7 +1185,7 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
|
||||
fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.eraseSegments";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let w = f64_or(p, "width", 8.0);
|
||||
let w = number_or(p, "width", 8.0, C)?;
|
||||
if !(w.is_finite() && w > 0.0) {
|
||||
return Err(bad(C, "width must be positive"));
|
||||
}
|
||||
@@ -1315,8 +1328,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.smoothRegion";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let radius = f64_or(p, "radius", 12.0).max(0.0);
|
||||
let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0);
|
||||
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
|
||||
let st = s.doc()?;
|
||||
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
|
||||
let mut plan = vec![];
|
||||
@@ -1363,7 +1376,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.joinScrub";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let tol = f64_or(p, "tolerance", 12.0).max(0.0);
|
||||
let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0);
|
||||
let st = s.doc()?;
|
||||
// Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start).
|
||||
let poly = {
|
||||
@@ -1462,7 +1475,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.blob";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let size = f64_or(p, "size", 10.0);
|
||||
let size = number_or(p, "size", 10.0, C)?;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
let i = p.get("index").and_then(Value::as_u64).map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
if !s.close_document(i) {
|
||||
return Err(bad("file.close", "no such document"));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize;
|
||||
let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
|
||||
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
|
||||
}
|
||||
|
||||
@@ -399,6 +399,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
@@ -423,6 +424,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::*;
|
||||
|
||||
@@ -54,6 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
cmd!(
|
||||
"effect.lightingPreset",
|
||||
"3D Lighting Preset",
|
||||
[],
|
||||
None,
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
|
||||
has_doc,
|
||||
lighting_preset
|
||||
),
|
||||
cmd!(
|
||||
"effect.expandAppearance",
|
||||
"Expand Appearance",
|
||||
@@ -129,8 +139,11 @@ fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
|
||||
pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
|
||||
|
||||
/// `effect.apply`: adds effect `effect` (or `id`) with `params`, its defaults filled in, to the
|
||||
/// targets' active item as one undo step. A 3D effect must fit each target, which takes only one;
|
||||
/// a Pathfinder effect on loose objects groups them first. → `{ids, index, item}`.
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
@@ -144,7 +157,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
}
|
||||
if id == GLASS {
|
||||
if texture_load_effect(id) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
@@ -162,13 +175,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if effects::is_three_d(id) {
|
||||
for target in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
if id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
} else {
|
||||
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
|
||||
effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if id == effects::REVOLVE && effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
if effects::has_revolve(n) || effects::has_solid(n) {
|
||||
return Err(bad(C, ONE_3D));
|
||||
@@ -299,6 +308,33 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
ids.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.lightingPreset";
|
||||
let patch = str_param(p, "preset")
|
||||
.and_then(effects::lighting_preset_params)
|
||||
.ok_or_else(|| bad(C, "preset must be standard, diffuse, topLeft or right"))?;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let item = item_target(s, p, C)?;
|
||||
// Validate the whole selection before changing any object. An unrelated effect or
|
||||
// a missing index must not leave just some objects with a new lighting rig.
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let slot = item.effects_item(&n.appearance, C)?;
|
||||
let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index));
|
||||
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
|
||||
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
|
||||
}
|
||||
}
|
||||
let mut options = p.clone();
|
||||
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
|
||||
map.remove("preset");
|
||||
map.insert("params".into(), patch);
|
||||
set_params(s, &options)
|
||||
}
|
||||
|
||||
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
|
||||
/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked
|
||||
/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`.
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
@@ -320,7 +356,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
super::params::check_effect_channel(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
if edited.iter().any(|e| texture_load_effect(e)) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
@@ -338,11 +374,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) {
|
||||
cur.extend(new);
|
||||
}
|
||||
if e.id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
@@ -373,6 +405,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Effect › Distort › Glass.
|
||||
const GLASS: &str = "distort.glass";
|
||||
/// Effect › Texture › Texturizer.
|
||||
const TEXTURIZER: &str = "texture.texturizer";
|
||||
|
||||
/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image
|
||||
/// file (Glass, Texturizer)?
|
||||
pub(crate) fn texture_load_effect(id: &str) -> bool {
|
||||
id == GLASS || id == TEXTURIZER
|
||||
}
|
||||
|
||||
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
|
||||
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
|
||||
@@ -442,18 +482,70 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
|
||||
Some((rasterfx::raster_image(d, n, below, true)?, below))
|
||||
}
|
||||
|
||||
/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that
|
||||
/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with
|
||||
/// `all` every member.
|
||||
fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) {
|
||||
/// Expand the pieces of `m`, art evaluated from the object of the same id (its pieces share that
|
||||
/// id): those made from its own fills and strokes, or with `all` every member. The other members
|
||||
/// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are
|
||||
/// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a
|
||||
/// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they
|
||||
/// have before that. Returns what each of those other members' expansions lists, by the member's
|
||||
/// new id.
|
||||
fn expand_pieces(
|
||||
d: &mut Document,
|
||||
m: &mut Node,
|
||||
all: bool,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
stroke_art: effects::StrokeArt,
|
||||
) -> std::collections::HashMap<NodeId, Vec<Listed>> {
|
||||
let id = m.id;
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
let mut expanded = std::collections::HashMap::new();
|
||||
for c in m.children_mut().into_iter().flatten() {
|
||||
let own = all || c.id == id;
|
||||
let c = Arc::make_mut(c);
|
||||
effects::fresh_ids(d, c, &mut seen);
|
||||
if own {
|
||||
let mut listed = None;
|
||||
if all || c.id == id {
|
||||
effects::expand_art(d, c, stroke_art);
|
||||
} else if has_seeded(c) && repeats_an_id(c, &seen) {
|
||||
listed = Some(expand_in_place(d, c, brushes));
|
||||
}
|
||||
fresh_ids_listed(d, c, &mut seen, listed.as_deref_mut().unwrap_or_default());
|
||||
if let Some(listed) = listed {
|
||||
expanded.insert(c.id, listed);
|
||||
}
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids?
|
||||
fn repeats_an_id(n: &Node, seen: &std::collections::HashSet<NodeId>) -> bool {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut repeats = false;
|
||||
n.walk(&mut |c| repeats |= seen.contains(&c.id) || !ids.insert(c.id));
|
||||
repeats
|
||||
}
|
||||
|
||||
/// An object whose appearance expanded: its id, and its place in the walk ([`Node::walk`]) of the
|
||||
/// art it was listed in. Art evaluated from an object can hold one id more than once, and the place
|
||||
/// tells which of those nodes it is.
|
||||
type Listed = (NodeId, usize);
|
||||
|
||||
/// [`expanded_node`] of `c` in place. Returns what the expansion lists, by places in the walk of `c`.
|
||||
fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec<Listed> {
|
||||
let mut listed = vec![];
|
||||
if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) {
|
||||
*c = e;
|
||||
}
|
||||
listed
|
||||
}
|
||||
|
||||
/// [`effects::fresh_ids`] on `c`, with each of `listed` (places in the walk of `c`) given the new
|
||||
/// id of the node at its place.
|
||||
fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet<NodeId>, listed: &mut [Listed]) {
|
||||
effects::fresh_ids(d, c, seen);
|
||||
let mut ids = vec![];
|
||||
c.walk(&mut |n| ids.push(n.id));
|
||||
for (id, at) in listed {
|
||||
if let Some(new) = ids.get(*at) {
|
||||
*id = *new;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,46 +554,71 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(mut n) = d.node(id).cloned() else { return };
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
let mut listed = vec![];
|
||||
let m = expanded_node(d, n, brushes, &mut listed);
|
||||
out.extend(listed.into_iter().map(|(id, _)| id));
|
||||
if let Some(m) = m
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when
|
||||
/// it has nothing to expand. `out` gets the objects whose appearance expands, by places in the walk
|
||||
/// of the expanded `n`, `n`'s first when its own does.
|
||||
fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<Listed>) -> Option<Node> {
|
||||
let id = n.id;
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
|
||||
// recoloured copy), then the rest of the appearance expands.
|
||||
let mut changed = false;
|
||||
// Is `n` in `out` yet?
|
||||
let mut listed_n = false;
|
||||
if effects::has_adjustment(&n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n)
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m.clone();
|
||||
out.push(id);
|
||||
out.push((id, 0));
|
||||
listed_n = true;
|
||||
n = m;
|
||||
changed = true;
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
// (A symbol instance has become its art, a group.)
|
||||
let container = is_container(&n);
|
||||
// Crop Marks: a group of the object and its marks, then the object expands on its own.
|
||||
// Crop Marks: the object and its marks become a group. The object expands on its own first,
|
||||
// with `id`: the canvas draws it with that id, which Random in a Transform effect takes its
|
||||
// seed from. Then the object and the marks get ids of their own.
|
||||
if let Some(mut m) = effects::crop_marks_art(&n) {
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
for c in m.children_mut().into_iter().flatten() {
|
||||
effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
|
||||
let mut expanded = vec![];
|
||||
for (i, c) in m.children_mut().into_iter().flatten().enumerate() {
|
||||
let c = Arc::make_mut(c);
|
||||
if i == 0 {
|
||||
expanded = expand_in_place(d, c, brushes);
|
||||
fresh_ids_listed(d, c, &mut seen, &mut expanded);
|
||||
} else {
|
||||
effects::fresh_ids(d, c, &mut seen);
|
||||
}
|
||||
}
|
||||
let object = m.children().and_then(|c| c.first()).map(|c| c.id);
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = m;
|
||||
out.push(id);
|
||||
if !listed_n {
|
||||
out.push((id, 0));
|
||||
}
|
||||
if let Some(object) = object {
|
||||
expand_node(d, object, brushes, out);
|
||||
}
|
||||
return;
|
||||
out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1)));
|
||||
return Some(m);
|
||||
}
|
||||
let image = raster_image(d, &n);
|
||||
let mut v = n.clone();
|
||||
rasterfx::strip_raster(&mut v, false);
|
||||
let mut stroke_art =
|
||||
|d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st);
|
||||
// Members of a group or layer that `expand_pieces` expanded, by id, with what their expansion lists.
|
||||
let mut members = std::collections::HashMap::new();
|
||||
let expanded = match image {
|
||||
// Blur, feather and inner glow change the object itself: it becomes the image (a layer
|
||||
// keeps it as its only member).
|
||||
@@ -510,7 +627,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
let m = if container {
|
||||
// A group's or layer's own fills and strokes become art among its members.
|
||||
effects::evaluate_container(&v).map(|mut m| {
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
@@ -523,7 +640,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
_ => None,
|
||||
};
|
||||
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
|
||||
expand_pieces(d, &mut m, true, &mut stroke_art);
|
||||
expand_pieces(d, &mut m, true, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
};
|
||||
@@ -537,20 +654,34 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(m) = expanded
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
if out.last() != Some(&id) {
|
||||
out.push(id);
|
||||
if let Some(m) = expanded {
|
||||
n = m;
|
||||
changed = true;
|
||||
if !listed_n {
|
||||
out.push((id, 0));
|
||||
}
|
||||
}
|
||||
if container {
|
||||
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
for c in children {
|
||||
expand_node(d, c, brushes, out);
|
||||
// The place of each member in the walk of `n`: after `n` and the members before it.
|
||||
let mut at = 1;
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let from = out.len();
|
||||
match members.remove(&c.id) {
|
||||
Some(listed) => out.extend(listed),
|
||||
None => {
|
||||
if let Some(e) = expanded_node(d, (**c).clone(), brushes, out) {
|
||||
*c = Arc::new(e);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, place) in &mut out[from..] {
|
||||
*place += at;
|
||||
}
|
||||
at += c.count();
|
||||
}
|
||||
}
|
||||
changed.then_some(n)
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -575,6 +706,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_lighting_presets.rs"]
|
||||
mod lighting_presets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -604,7 +604,7 @@ fn seeded_here(n: &Node) -> bool {
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
|
||||
/// ([`seeded_here`])?
|
||||
fn has_seeded(n: &Node) -> bool {
|
||||
pub(crate) fn has_seeded(n: &Node) -> bool {
|
||||
seeded_here(n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
|
||||
|
||||
@@ -136,7 +136,15 @@ impl Builder {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
|
||||
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
|
||||
.map(|(i, (name, rect))| Artboard {
|
||||
id: i as u32 + 1,
|
||||
name,
|
||||
rect,
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
})
|
||||
.collect();
|
||||
if self.doc.artboards.is_empty() {
|
||||
return Err(err("the Affinity document has no page with a size"));
|
||||
|
||||
@@ -94,6 +94,12 @@ pub const UNSUPPORTED: &[Unsupported] = &[
|
||||
extensions: &["pict", "pct", "pic"],
|
||||
hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first",
|
||||
},
|
||||
Unsupported {
|
||||
id: "pmd",
|
||||
label: "PageMaker",
|
||||
extensions: &["pmd"],
|
||||
hint: "PageMaker (.pmd) is not supported: open the layout in PageMaker and export PDF or EPS, or place a PDF export here",
|
||||
},
|
||||
];
|
||||
|
||||
/// The unsupported format an id or extension (any case, leading dot allowed) names.
|
||||
|
||||
@@ -340,7 +340,7 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
|
||||
|
||||
/// `d` with one artboard, `rect`.
|
||||
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, locked: false, show_center_mark: false, show_cross_hairs: false, color: None }];
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -197,12 +197,38 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `e` the error of a file whose pages are only a placeholder
|
||||
/// ([`vectorcraft_pdf::PdfError::PlaceholderOnly`])?
|
||||
pub(crate) fn is_placeholder(e: &EngineError) -> bool {
|
||||
e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string()
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// A page read from a PDF or `.ai` (`read`, as [`super::page_document`] reads it) → (document,
|
||||
/// notes, false); for a `.ai` saved without PDF compatibility (its page only a placeholder), the
|
||||
/// file `name` read as `document.open` reads it with `password`, from its editing copy alone →
|
||||
/// (document, notes, true).
|
||||
pub(crate) fn page_or_editing_data(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
read: Result<(Document, Vec<String>)>,
|
||||
) -> Result<(Document, Vec<String>, bool)> {
|
||||
match read {
|
||||
Ok((doc, notes)) => Ok((doc, notes, false)),
|
||||
Err(e) if is_placeholder(&e) => {
|
||||
let l = load_with(name, bytes, &LoadOptions { password: password.map(str::to_string), ..Default::default() })?;
|
||||
Ok((l.doc, l.warnings, true))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -218,7 +244,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data)
|
||||
.map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
Ok((r.document, r.warnings))
|
||||
})?
|
||||
}
|
||||
@@ -256,7 +283,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
Err(e) if is_placeholder(&e) => {
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
|
||||
.unwrap_or(Err(e))
|
||||
|
||||
@@ -59,7 +59,9 @@ pub(crate) use export::{export_crop, isolated};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::is_placeholder;
|
||||
pub(crate) use load::native_file;
|
||||
pub(crate) use load::page_or_editing_data;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
@@ -1153,9 +1155,11 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
/// document's path on that share. A path on a drive mapped to a network share other than the
|
||||
/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path
|
||||
/// reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
document_path_on(stored, document, share_path, drive_share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
@@ -1180,6 +1184,68 @@ pub(crate) fn document_path_with(
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other
|
||||
/// than the document's (or, for a document on this computer or without a path, to any share) as
|
||||
/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its
|
||||
/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A
|
||||
/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up
|
||||
/// on its share as `Z:\…` is.
|
||||
pub(crate) fn document_path_on(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl Fn(&str) -> Option<String>,
|
||||
drive_share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d));
|
||||
let path = document_path_with(stored, document, share)?;
|
||||
let document = document.map(absolute_path);
|
||||
let Some(drive) = drive_letter(&path) else { return Ok(path) };
|
||||
if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) {
|
||||
return Ok(path);
|
||||
}
|
||||
let Some(on_share) = drive_share(&path) else { return Ok(path) };
|
||||
let home = document.map(|d| share(&d).unwrap_or(d));
|
||||
craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`).
|
||||
fn drive_letter(path: &str) -> Option<char> {
|
||||
let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars();
|
||||
match (chars.next(), chars.next(), chars.next()) {
|
||||
(Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to
|
||||
/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read
|
||||
/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn drive_share_path(path: &str) -> Option<String> {
|
||||
may_read(path).ok()?;
|
||||
drive_share_of(path, |root| std::fs::canonicalize(root).ok())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn drive_share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `path` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a
|
||||
/// document, also for a path written `\\?\Y:\…`.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let path = path.strip_prefix(r"\\?\").unwrap_or(path);
|
||||
share_of(path, |folder| {
|
||||
let (root, rest) = (folder.get(..3)?, folder.get(3..)?);
|
||||
let root = final_path(root)?;
|
||||
let root = root.to_str()?.trim_end_matches('\\');
|
||||
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
|
||||
})
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
|
||||
@@ -346,6 +346,77 @@ fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// [`PLACEHOLDER`]'s `.ai` files as `dataBase64`: the sample's editing data, damaged editing data
|
||||
/// and none.
|
||||
fn placeholder_files() -> [String; 3] {
|
||||
[compressed(&ai::sample_data()), b"%AI12_CompressedData not zlib".to_vec(), vec![]]
|
||||
.map(|private| vectorcraft_format::base64_encode(&ai::ai(&private, PLACEHOLDER)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
|
||||
let [sample, damaged, plain] = placeholder_files();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// clipboard.importPdf (the PDF flavor's command) takes every object of both layers, as
|
||||
// document.open reads them.
|
||||
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": sample})).unwrap();
|
||||
assert_eq!(r["count"], json!(7), "{r}");
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
|
||||
assert_eq!(names(&art), ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
|
||||
assert_eq!(art.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": damaged})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": plain})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
|
||||
let [sample, damaged, plain] = placeholder_files();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// The place takes the art of the layers that show on the artboard and clips it to the artboard.
|
||||
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": sample, "link": false})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
|
||||
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
|
||||
let kids = placed.children().unwrap();
|
||||
assert_eq!(names(&kids[1..]), ["Pair", "Ring", "Window"], "after the clipping path");
|
||||
// With `crop: "bounding"` the size is the bounds of the art that shows. They run from the red
|
||||
// square's left edge to the window's right one; the pair's hidden square, stroked 2 pt wide,
|
||||
// is left out.
|
||||
let r = s.execute("file.place.info", &json!({"name": "art.ai", "dataBase64": sample, "crop": "bounding"})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(170.0), Some(40.0)), "{r}");
|
||||
// The place takes the art of both layers that show.
|
||||
let square = |x: f64| format!("0 g\n{}f\n", ai::rect(x, 10.0, x + 40.0, 50.0));
|
||||
let two = ai::editing_data(200.0, 100.0, &[ai::layer("Back", &square(10.0)), ai::layer("Front", &square(110.0))].concat());
|
||||
let two = vectorcraft_format::base64_encode(&ai::ai(&compressed(two.as_bytes()), PLACEHOLDER));
|
||||
let r = s.execute("file.place.info", &json!({"name": "two.ai", "dataBase64": two, "crop": "bounding"})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(140.0), Some(40.0)), "{r}");
|
||||
// The file has two 100-point artboards and one placeholder page. The second artboard lies 50
|
||||
// below the first. A square of 40 lies on the first and an oblong of 60 × 40 on the second.
|
||||
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
|
||||
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
|
||||
let art = format!("0 g\n{}f\n{}f\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(10.0, -140.0, 70.0, -100.0));
|
||||
let paged = ai::editing_data(100.0, 100.0, &(boards.to_string() + &ai::layer("Art", &art)));
|
||||
let paged = vectorcraft_format::base64_encode(&ai::ai(&compressed(paged.as_bytes()), PLACEHOLDER));
|
||||
let mut size = |page: u64| {
|
||||
let r = s.execute("file.place.info", &json!({"name": "boards.ai", "dataBase64": paged, "page": page, "crop": "bounding"}));
|
||||
r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(size(1), Ok((Some(40.0), Some(40.0))));
|
||||
assert_eq!(size(2), Ok((Some(60.0), Some(40.0))));
|
||||
let third = size(3);
|
||||
assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}");
|
||||
let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ fn formats_list_dxf_and_what_can_not_be_written() {
|
||||
}
|
||||
assert!(r["writable"].as_array().unwrap().contains(&json!("dxf")));
|
||||
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(unsupported, ["dwg", "pict"]);
|
||||
assert_eq!(unsupported, ["dwg", "pict", "pmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,4 +191,9 @@ fn dwg_and_pict_say_what_to_use_instead() {
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("DWG is not available"), "{e}");
|
||||
let e = s
|
||||
.execute("document.open", &json!({"name": "brochure.PMD", "dataBase64": vectorcraft_format::base64_encode(b"PageMaker")}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("brochure.PMD") && e.contains("PageMaker"), "{e}");
|
||||
}
|
||||
@@ -473,7 +473,8 @@ fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let clipping = |id: NodeId| {
|
||||
d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id))
|
||||
};
|
||||
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect())
|
||||
let editable = d.editable();
|
||||
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect())
|
||||
}
|
||||
|
||||
// ---------- preview ----------
|
||||
|
||||
@@ -301,7 +301,8 @@ fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
|
||||
}
|
||||
|
||||
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
|
||||
/// families that aren't available: their type is drawn in the fallback font, not the font it names.
|
||||
/// families that aren't available (their type is drawn in the fallback font, not the font it
|
||||
/// names) or styles that its family doesn't have (the closest style is drawn).
|
||||
pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||||
substitution_warning_of(used_fonts(d))
|
||||
}
|
||||
@@ -309,20 +310,34 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||||
/// [`substitution_warning`] for type set in `fonts`.
|
||||
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut missing: Vec<String> = fonts
|
||||
.into_iter()
|
||||
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||||
.map(|(family, _, _)| family)
|
||||
.collect();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
let mut styles: BTreeSet<String> = BTreeSet::new();
|
||||
for (family, style, _) in fonts {
|
||||
match db.resolve(&family, &style) {
|
||||
None | Some((_, vectorcraft_text::FontMatch::Missing)) => missing.push(family),
|
||||
Some((face, vectorcraft_text::FontMatch::Style)) => {
|
||||
styles.insert(format!("{family} {style} was drawn in {} {}", face.family, face.style));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// Sorted by family: its styles and versions are neighbours.
|
||||
missing.dedup();
|
||||
(!missing.is_empty()).then(|| {
|
||||
format!(
|
||||
let mut parts = Vec::new();
|
||||
if !missing.is_empty() {
|
||||
parts.push(format!(
|
||||
"fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)",
|
||||
vectorcraft_text::FALLBACK_FAMILY,
|
||||
missing.join(", ")
|
||||
)
|
||||
})
|
||||
));
|
||||
}
|
||||
if !styles.is_empty() {
|
||||
parts.push(format!(
|
||||
"font styles that aren't available were written in the closest style: {} (pick a style the family has)",
|
||||
styles.into_iter().collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
(!parts.is_empty()).then(|| parts.join("; "))
|
||||
}
|
||||
|
||||
fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
|
||||
@@ -585,6 +600,16 @@ mod open_tests {
|
||||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_warns_when_a_style_is_drawn_in_another_style() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
|
||||
let out = s.execute("document.export", &json!({"format": "pdf"})).unwrap();
|
||||
let warnings = out["warnings"].to_string();
|
||||
assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -227,7 +227,7 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
/// The `index` param, else the selected point.
|
||||
fn point_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
match p.get("index") {
|
||||
Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")),
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad(cmd, "`index` must be a point index")),
|
||||
None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")),
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Bool(true)) => s.selected_freeform_point(),
|
||||
Some(_) => return Err(bad(C, "`line` must be true or false")),
|
||||
};
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let given = given_color(s, p);
|
||||
let index = edit(s, p, C, |f, sp| {
|
||||
let q = sp.to_paint * at;
|
||||
@@ -266,6 +267,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.setPoint";
|
||||
let i = point_index(s, p, C)?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let given = given_color(s, p);
|
||||
edit(s, p, C, |f, sp| {
|
||||
check_index(f, i)?;
|
||||
@@ -318,7 +320,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => return Err(bad(C, "missing `index` (a point index, or null to clear)")),
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
|
||||
let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?;
|
||||
let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||
check_index(&f, i).map_err(|e| bad(C, e))?;
|
||||
Some(i)
|
||||
|
||||
@@ -60,7 +60,7 @@ fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
|
||||
let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?;
|
||||
let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len();
|
||||
if i >= n {
|
||||
return Err(bad(C, format!("no stop {i} (the gradient has {n})")));
|
||||
@@ -381,6 +381,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
||||
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
||||
};
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
|
||||
let stored = stop_colors(s, p, p);
|
||||
// Stops naming swatches take their colours from them.
|
||||
|
||||
+540
-22
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Column…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); type in the design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits, the first digit giving the least number of places before the decimal point (zeros added in front; 0 = as many as the value needs) and the second digit giving the places after it (rounded half away from zero), at the type's own size where the design's copy puts it (a repeating design shows it once, where a whole copy ending at the value end puts it; type inside a group that clips, is hidden or has its own opacity, blend mode, isolation, knockout, appearance or opacity mask, or inside a blend, compound shape, envelope, repeat or intertwine, stays there and is filled in each copy, drawn with the design; type inside a symbol stays as typed), and is left out of the legend swatch (a design of such type alone gets the default swatch); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
has_selection,
|
||||
set_column
|
||||
),
|
||||
@@ -373,6 +373,7 @@ impl Gen<'_> {
|
||||
let style = label_style(self.styles, role);
|
||||
let mut t = TextObject::point(at, s, style);
|
||||
t.para.justify = justify;
|
||||
t.system_layout = self.d.setup.system_layout;
|
||||
refresh_bounds(&mut t);
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
@@ -413,10 +414,11 @@ impl Gen<'_> {
|
||||
None => self.path(shapes::rectangle(cell), fill, stroke, w),
|
||||
}
|
||||
}
|
||||
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
|
||||
/// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design
|
||||
/// draws nothing else, its [`Self::marker`].
|
||||
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
|
||||
let fitted = match column {
|
||||
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
|
||||
let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) {
|
||||
Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell),
|
||||
_ => None,
|
||||
};
|
||||
match fitted {
|
||||
@@ -552,12 +554,164 @@ fn fit_affine(frame: Rect, cell: Rect) -> Affine {
|
||||
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
|
||||
const COLUMN_BUDGET: usize = 200_000;
|
||||
|
||||
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
|
||||
/// those guides.
|
||||
/// A series' column design (Object › Graph › Column): its options; its art without guide lines and without the totals
|
||||
/// taken out of it ([`without_totals`]); the bounds of those guides; those totals; its frame (the art's without guides,
|
||||
/// totals included, [`design_frame`]); whether its art still holds totals (inside groups that do more than group, or
|
||||
/// inside other objects that hold objects), which are filled where they are; and its art without any total for the
|
||||
/// legend swatch, `None` when that draws nothing.
|
||||
struct ColumnArt {
|
||||
options: ColumnDesign,
|
||||
art: Node,
|
||||
guides: Vec<Rect>,
|
||||
totals: Vec<Node>,
|
||||
frame: Option<Rect>,
|
||||
in_place: bool,
|
||||
legend: Option<Node>,
|
||||
}
|
||||
|
||||
impl ColumnArt {
|
||||
/// A copy of `art` with the design's frame fitted to `cell`, as [`fit_design`] fits it. Fresh ids.
|
||||
fn fit(&self, d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
|
||||
let art = super::place::transformed(art.clone(), fit_affine(self.frame?, cell));
|
||||
Some(Arc::new(d.reid(&art)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The column design totals in `s` (Add totals to a column design): each `%` followed by two digits, as its byte offset,
|
||||
/// the places before the decimal point (the first digit) and the places after it (the second), left to right. A `%` not
|
||||
/// followed by two digits is plain text.
|
||||
fn total_spans(s: &str) -> Vec<(usize, usize, usize)> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while let Some(w) = b.get(i..i + 3) {
|
||||
match *w {
|
||||
[b'%', x, y] if x.is_ascii_digit() && y.is_ascii_digit() => {
|
||||
out.push((i, usize::from(x - b'0'), usize::from(y - b'0')));
|
||||
i += 3;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Value `v` as a column design total shows it: rounded half away from zero, from its shortest decimal form, to `after`
|
||||
/// places after the decimal point, zeros added as needed; and with at least `before` places before the point, zeros
|
||||
/// added in front (0: as many as the value needs). The places before the point are never cut, and a value that rounds
|
||||
/// to 0 has no minus sign.
|
||||
fn total_text(v: f64, before: usize, after: usize) -> String {
|
||||
if !v.is_finite() {
|
||||
return fmt_value(v);
|
||||
}
|
||||
let s = format!("{}", v.abs());
|
||||
let (int, frac) = s.split_once('.').unwrap_or((s.as_str(), ""));
|
||||
// The digits kept, the point `after` places from the end.
|
||||
let mut digits: Vec<u8> = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(after)).collect();
|
||||
if frac.as_bytes().get(after).is_some_and(|d| *d >= b'5') {
|
||||
let mut carry = true;
|
||||
for d in digits.iter_mut().rev() {
|
||||
if *d == b'9' {
|
||||
*d = b'0';
|
||||
} else {
|
||||
*d += 1;
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
digits.insert(0, b'1');
|
||||
}
|
||||
}
|
||||
let (whole, places) = digits.split_at(digits.len().saturating_sub(after));
|
||||
let mut out = String::new();
|
||||
if v < 0.0 && digits.iter().any(|d| *d != b'0') {
|
||||
out.push('-');
|
||||
}
|
||||
out.extend(std::iter::repeat_n('0', before.saturating_sub(whole.len())));
|
||||
out.extend(whole.iter().map(|d| char::from(*d)));
|
||||
if after > 0 {
|
||||
out.push('.');
|
||||
out.extend(places.iter().map(|d| char::from(*d)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Type `t` with each total in it ([`total_spans`]) replaced by value `v` ([`total_text`]), styled as its `%` was; true
|
||||
/// when it held one.
|
||||
fn fill_totals(t: &mut TextObject, v: f64) -> bool {
|
||||
let spans = total_spans(&t.plain_text());
|
||||
// Last first, so the offsets of earlier ones stay valid.
|
||||
for &(at, before, after) in spans.iter().rev() {
|
||||
let s = total_text(v, before, after);
|
||||
t.splice_paras(at, at + 3, &s);
|
||||
vectorcraft_text::edit::replace_range(&mut t.runs, at, at + 3, &s);
|
||||
}
|
||||
if !spans.is_empty() {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
!spans.is_empty()
|
||||
}
|
||||
|
||||
/// Is `n` type that holds a total ([`total_spans`])?
|
||||
fn holds_total(n: &Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if !total_spans(&t.plain_text()).is_empty())
|
||||
}
|
||||
|
||||
/// Does group `n` only group its children: no clipping, hiding, opacity, blend mode, isolation, knockout, appearance of
|
||||
/// its own or opacity mask, so that type taken out of it draws as it did inside it?
|
||||
fn only_groups(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. })
|
||||
&& n.visible
|
||||
&& n.opacity == 1.0
|
||||
&& n.blend == vectorcraft_color::BlendMode::Normal
|
||||
&& !n.isolate
|
||||
&& n.knockout == vectorcraft_doc::Knockout::default()
|
||||
&& n.appearance.items.is_empty()
|
||||
&& n.appearance.effects.is_empty()
|
||||
&& n.extras().mask.is_none()
|
||||
}
|
||||
|
||||
/// Is `n` a clipping group whose clipping path is type that holds a total?
|
||||
fn clipped_by_total(n: &Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Group { clip: true, children } if children.first().is_some_and(|c| holds_total(c)))
|
||||
}
|
||||
|
||||
/// `art` without the type in it that holds a total, inside groups at any depth that only group ([`only_groups`]), and
|
||||
/// that type, pushed to `totals` in paint order (Add totals to a column design); with `all`, without every total inside
|
||||
/// any group or other object that holds objects (a blend, compound shape, envelope, repeat or intertwine), and with
|
||||
/// each group that such type clips left empty. A design that is such type alone leaves an empty group.
|
||||
fn without_totals(art: &Node, all: bool, totals: &mut Vec<Node>) -> Node {
|
||||
if holds_total(art) || (all && clipped_by_total(art)) {
|
||||
totals.push(art.clone());
|
||||
return Node::group(art.id, vec![]);
|
||||
}
|
||||
let mut n = art.clone();
|
||||
let take = all || only_groups(&n);
|
||||
if take && let Some(children) = n.children_mut() {
|
||||
for c in std::mem::take(children) {
|
||||
if holds_total(&c) {
|
||||
totals.push((*c).clone());
|
||||
} else if c.children().is_some() {
|
||||
children.push(Arc::new(without_totals(&c, all, totals)));
|
||||
} else {
|
||||
children.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `n` with the totals in it at any depth, in groups and the other objects that hold objects, showing value `v` where
|
||||
/// they are ([`fill_totals`]).
|
||||
fn fill_in_place(n: &mut Node, v: f64) {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
fill_totals(t, v);
|
||||
} else if let Some(children) = n.children_mut() {
|
||||
for c in children.iter_mut() {
|
||||
fill_in_place(Arc::make_mut(c), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `art` without the paths made into guides, at any depth.
|
||||
@@ -585,7 +739,16 @@ fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
|
||||
guides.push(b);
|
||||
}
|
||||
});
|
||||
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
|
||||
let art = without_guides(art);
|
||||
let frame = design_frame(&art);
|
||||
let legend = without_totals(&art, true, &mut vec![]);
|
||||
let mut draws = false;
|
||||
legend.walk(&mut |n| draws |= n.children().is_none());
|
||||
let mut totals = vec![];
|
||||
let art = without_totals(&art, false, &mut totals);
|
||||
let mut in_place = false;
|
||||
art.walk(&mut |n| in_place |= holds_total(n));
|
||||
Some(ColumnArt { options: options.clone(), art, guides, totals, frame, in_place, legend: draws.then_some(legend) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -621,30 +784,42 @@ impl Allowance {
|
||||
}
|
||||
}
|
||||
|
||||
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
|
||||
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
|
||||
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
|
||||
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
|
||||
/// Column or bar `rect` of value `value` drawn with column design `c` (Object › Graph › Column), as a group of fresh art,
|
||||
/// its copies spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a
|
||||
/// column above zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot
|
||||
/// points per unit of value. The design's totals show `value` and come last: each keeps its size and is moved to where
|
||||
/// the design's copy puts the point it is set from; in a repeating design they are drawn once, where a whole copy ending
|
||||
/// at the value end puts them, and a sliding design moves each with the part of the design on its side of the guide.
|
||||
/// Totals left in the art ([`ColumnArt`]) show `value` in each copy, drawn with it. `None` for art with no area, for a
|
||||
/// column of no length and when `left` runs out, which are drawn plain.
|
||||
fn column_art(
|
||||
d: &mut Document,
|
||||
c: &ColumnArt,
|
||||
rect: Rect,
|
||||
(rect, value): (Rect, f64),
|
||||
(horizontal, far_low): (bool, bool),
|
||||
per_unit: f64,
|
||||
left: &mut Allowance,
|
||||
) -> Option<Arc<Node>> {
|
||||
let frame = design_frame(&c.art)?;
|
||||
let frame = c.frame?;
|
||||
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
|
||||
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
|
||||
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
|
||||
// A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved
|
||||
// by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when
|
||||
// `keep_low` (two objects more).
|
||||
let copy = |d: &mut Document, a: Affine| {
|
||||
let mut art = d.reid(&super::place::transformed(c.art.clone(), a));
|
||||
if c.in_place {
|
||||
fill_in_place(&mut art, value);
|
||||
}
|
||||
art
|
||||
};
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| left.take(cost).then(|| Arc::new(copy(d, fit_affine(frame, cell))));
|
||||
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
|
||||
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
|
||||
let art = left.take(cost + 2).then(|| copy(d, a))?;
|
||||
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
|
||||
// stroke counts only in its own object's box.
|
||||
let mut b = vectorcraft_render::painted_bounds(d, &art);
|
||||
@@ -653,7 +828,8 @@ fn column_art(
|
||||
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
|
||||
}
|
||||
});
|
||||
let b = b.or_else(|| art.geometric_bounds())?;
|
||||
// A copy that draws nothing (a design of totals alone) needs no clip.
|
||||
let Some(b) = b.or_else(|| art.geometric_bounds()) else { return Some(Arc::new(art)) };
|
||||
let keep = match (along_x, keep_low) {
|
||||
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
|
||||
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
|
||||
@@ -669,7 +845,35 @@ fn column_art(
|
||||
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
|
||||
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
|
||||
};
|
||||
// The column's group: `parts`, then the totals, each showing `value` at its own size, moved as `a` moves the point it
|
||||
// is set from; with `sliding` (the kept part's map, the stretched part's map and the guide, as [`slide`] gives them),
|
||||
// as the map of the part on its side of the guide moves it. One object each.
|
||||
let group = |d: &mut Document, mut parts: Vec<Arc<Node>>, a: Affine, sliding: Option<(Affine, Affine, f64)>, left: &mut Allowance| {
|
||||
for t in &c.totals {
|
||||
if !left.take(1) {
|
||||
return None;
|
||||
}
|
||||
let mut n = t.clone();
|
||||
let mut by = Affine::IDENTITY;
|
||||
if let NodeKind::Text(text) = &mut n.kind {
|
||||
// A move only, so the type keeps its size; pattern paints move with it.
|
||||
let p = text.xf * Point::ZERO;
|
||||
let a = match sliding {
|
||||
Some((kept, stretched, at)) => {
|
||||
let q = kept * p;
|
||||
if (horizontal && q.x >= at) || (!horizontal && q.y <= at) { kept } else { stretched }
|
||||
}
|
||||
None => a,
|
||||
};
|
||||
fill_totals(text, value);
|
||||
by = Affine::translate(a * p - p);
|
||||
}
|
||||
parts.push(Arc::new(d.reid(&super::place::transformed(n, by))));
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
};
|
||||
let mut parts = vec![];
|
||||
let (mut label, mut sliding) = (fit_affine(frame, rect), None);
|
||||
match c.options.column_type {
|
||||
ColumnType::Uniform => {
|
||||
// Scaled both ways to the column's length, centered across it.
|
||||
@@ -680,14 +884,17 @@ fn column_art(
|
||||
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
|
||||
};
|
||||
parts.push(fit(d, cell, left)?);
|
||||
label = fit_affine(frame, cell);
|
||||
}
|
||||
ColumnType::Repeating => {
|
||||
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
|
||||
// is drawn vertically scaled.
|
||||
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
|
||||
parts.push(fit(d, rect, left)?);
|
||||
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
|
||||
return group(d, parts, label, None, left);
|
||||
};
|
||||
// The totals go where a whole copy ending at the value end puts them.
|
||||
label = fit_affine(frame, piece(length(rect) - unit, length(rect)));
|
||||
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
|
||||
let whole = ((length(rect) + 1e-6) / unit).floor();
|
||||
let rest = length(rect) - whole * unit;
|
||||
@@ -717,12 +924,13 @@ fn column_art(
|
||||
// Each copy cut at the guide, on its own side.
|
||||
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
|
||||
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
|
||||
sliding = Some((kept, stretched, at));
|
||||
}
|
||||
None => parts.push(fit(d, rect, left)?),
|
||||
},
|
||||
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
group(d, parts, label, sliding, left)
|
||||
}
|
||||
|
||||
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
|
||||
@@ -1240,7 +1448,8 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnA
|
||||
let rect = bar_rect(a, bar, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
// A column drawn with a design casts no shadow.
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns))
|
||||
{
|
||||
Some(n) => {
|
||||
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
@@ -1281,7 +1490,7 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnA
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let rect = bar_rect(a, w, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
@@ -4100,6 +4309,315 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a
|
||||
/// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5
|
||||
/// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from.
|
||||
fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] {
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
|
||||
let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap());
|
||||
let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap());
|
||||
let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap());
|
||||
let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO,
|
||||
k => panic!("type: {k:?}"),
|
||||
};
|
||||
let points = [origin(s, above), origin(s, below)];
|
||||
s.execute("select.set", &json!({"ids": [votes, below]})).unwrap();
|
||||
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
|
||||
let mut ids = vec![frame, above, inner];
|
||||
if guide {
|
||||
let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.push(line);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": name})).unwrap();
|
||||
points
|
||||
}
|
||||
|
||||
/// The text of each piece of type inside `n`, in paint order.
|
||||
fn texts_in(n: &vectorcraft_doc::Node) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
out.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size.
|
||||
fn totals_of(nodes: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, vectorcraft_geom::Point)> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let [a, b, c, d, x, y] = t.xf.as_coeffs();
|
||||
assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf);
|
||||
(t.plain_text(), vectorcraft_geom::Point::new(x, y))
|
||||
}
|
||||
k => panic!("a total: {k:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool {
|
||||
(a - b).hypot() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_design_totals_show_each_columns_value_at_their_own_size() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let [above, below] = save_totals_design(&mut s, "Ballot", false);
|
||||
let id = one_series(&mut s, "column", &[35.0, 7.256]);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap();
|
||||
let check = |s: &Session, want: [[&str; 2]; 2]| {
|
||||
let parts = series_parts(s, id, 0);
|
||||
for (column, want) in parts.iter().zip(want) {
|
||||
let children = column.children().unwrap();
|
||||
// The design's copy keeps the type without a total, stretched with the design, and leaves the totals out.
|
||||
assert_eq!(texts_in(&children[0]), ["Votes"]);
|
||||
let frame = frames(column)[0];
|
||||
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
|
||||
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
|
||||
let totals = totals_of(&children[1..]);
|
||||
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), want);
|
||||
// Each set from where the design's copy puts the point it was set from.
|
||||
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
|
||||
}
|
||||
// The legend swatch keeps the type without a total and leaves the totals out.
|
||||
assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]);
|
||||
};
|
||||
check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]);
|
||||
// New data draws the new values.
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap();
|
||||
check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]);
|
||||
// The design in the Graph Design list is left as it was.
|
||||
assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]);
|
||||
// A bar below zero shows its minus sign, its totals moved with the design along the bar.
|
||||
let bars = one_series(&mut s, "bar", &[-15.0]);
|
||||
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap();
|
||||
let bar = series_parts(&s, bars, 0)[0].clone();
|
||||
let frame = frames(&bar)[0];
|
||||
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
|
||||
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
|
||||
let totals = totals_of(&bar.children().unwrap()[1..]);
|
||||
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["-015.0", "-15.00 and -15"]);
|
||||
assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
|
||||
// A design that is a single piece of type with a total shows the value alone, and its legend swatch is the
|
||||
// default square.
|
||||
let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [one]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Count"})).unwrap();
|
||||
let count = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap();
|
||||
let parts = series_parts(&s, count, 0);
|
||||
assert_eq!(texts_in(&parts[0]), ["35"]);
|
||||
assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1);
|
||||
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
|
||||
// Repeating, with a last copy to chop, it still shows the value.
|
||||
s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap();
|
||||
let column = series_parts(&s, count, 0)[0].clone();
|
||||
assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let [above, below] = save_totals_design(&mut s, "Ballot", true);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let children = column.children().unwrap();
|
||||
// Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut.
|
||||
assert_eq!(children.len(), 6, "{children:?}");
|
||||
assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped");
|
||||
assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"]));
|
||||
// Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column.
|
||||
let sx = plain.width() / 10.0;
|
||||
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0);
|
||||
let totals = totals_of(&children[4..]);
|
||||
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["035.0", "35.00 and 35"]);
|
||||
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}");
|
||||
// Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the
|
||||
// design with the part stretched down to the zero line.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let children = column.children().unwrap();
|
||||
assert_eq!(children.len(), 4, "two halves and two totals: {children:?}");
|
||||
let k = (140.0 - 5.0 * sx) / 5.0;
|
||||
let totals = totals_of(&children[2..]);
|
||||
let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx);
|
||||
let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k);
|
||||
assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}");
|
||||
// A total's pattern fill moves with it.
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let origin = vectorcraft_geom::Point::new(0.0, -2.0);
|
||||
let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default());
|
||||
let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into();
|
||||
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() };
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
|
||||
let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0);
|
||||
assert_eq!(text, "35");
|
||||
let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") };
|
||||
assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
|
||||
let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [total]})).unwrap();
|
||||
let faded = id_of(s.execute("object.group", &json!({})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [faded]})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Faded"})).unwrap();
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap();
|
||||
// The total stays in its half-opaque group: each of the four copies shows the value there.
|
||||
let parts = series_parts(&s, id, 0);
|
||||
let copies = parts[0].children().unwrap();
|
||||
assert_eq!(copies.len(), 4, "{copies:?}");
|
||||
let mut faded_groups = 0;
|
||||
for c in copies {
|
||||
assert_eq!(texts_in(c), ["35"]);
|
||||
c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6));
|
||||
}
|
||||
assert_eq!(faded_groups, 4);
|
||||
// The legend swatch shows the design without it.
|
||||
let swatch = parts.last().unwrap();
|
||||
assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind);
|
||||
// Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out
|
||||
// what it clips.
|
||||
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
|
||||
let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
|
||||
let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap();
|
||||
let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Masked"})).unwrap();
|
||||
let masked = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap();
|
||||
let parts = series_parts(&s, masked, 0);
|
||||
let clip_groups = |n: &vectorcraft_doc::Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Group { clip: true, children } = &c.kind {
|
||||
out.push(children.first().map(|m| texts_in(m)).unwrap_or_default());
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value");
|
||||
let swatch = parts.last().unwrap();
|
||||
let mut drawn = 0;
|
||||
swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. })));
|
||||
assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}");
|
||||
// Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves
|
||||
// them out.
|
||||
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap());
|
||||
let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap());
|
||||
let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [first, second]})).unwrap();
|
||||
let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Blended"})).unwrap();
|
||||
let blended = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap();
|
||||
let parts = series_parts(&s, blended, 0);
|
||||
let mut blends = vec![];
|
||||
parts[0].walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Blend { .. }) {
|
||||
blends.push(texts_in(n));
|
||||
}
|
||||
});
|
||||
assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]);
|
||||
assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap());
|
||||
// A design that is that blend alone gets the default legend swatch.
|
||||
s.execute("select.set", &json!({"ids": [blend]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Keys"})).unwrap();
|
||||
let keys = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap();
|
||||
let parts = series_parts(&s, keys, 0);
|
||||
assert_eq!(texts_in(&parts[0]), ["35", "35"]);
|
||||
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
|
||||
// A design of a total inside two plain groups shows the value, and its legend swatch is the default square.
|
||||
let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [total]})).unwrap();
|
||||
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [inner]})).unwrap();
|
||||
let outer = id_of(s.execute("object.group", &json!({})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [outer]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Nested"})).unwrap();
|
||||
let nested = one_series(&mut s, "column", &[35.0]);
|
||||
s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap();
|
||||
let parts = series_parts(&s, nested, 0);
|
||||
assert_eq!(texts_in(&parts[0]), ["35"]);
|
||||
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() {
|
||||
for (v, before, after, want) in [
|
||||
(122.0, 3, 0, "122"),
|
||||
(122.0, 0, 0, "122"),
|
||||
(5.0, 3, 1, "005.0"),
|
||||
(1234.0, 2, 0, "1234"),
|
||||
(2.5, 0, 0, "3"),
|
||||
(-2.5, 0, 0, "-3"),
|
||||
(0.25, 0, 0, "0"),
|
||||
(0.25, 0, 2, "0.25"),
|
||||
(1.005, 0, 2, "1.01"),
|
||||
(0.1 + 0.2, 0, 2, "0.30"),
|
||||
(9.995, 0, 2, "10.00"),
|
||||
(99.96, 0, 1, "100.0"),
|
||||
(-0.004, 0, 2, "0.00"),
|
||||
(-7.25, 2, 1, "-07.3"),
|
||||
(7.0, 0, 9, "7.000000000"),
|
||||
(1e21, 0, 0, "1000000000000000000000"),
|
||||
] {
|
||||
assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}");
|
||||
}
|
||||
// Each `%` and two digits, left to right; any other `%` stays.
|
||||
let style = CharStyle::default;
|
||||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style());
|
||||
assert!(super::fill_totals(&mut t, 7.25));
|
||||
assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%");
|
||||
// A total split between two runs takes the style of its `%`.
|
||||
let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() });
|
||||
t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)];
|
||||
assert!(super::fill_totals(&mut t, 7.25));
|
||||
assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::<Vec<_>>(), [("a 007.3", 10.0), (" b", 20.0)]);
|
||||
// Type without a total is left as it is.
|
||||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style());
|
||||
assert!(!super::fill_totals(&mut t, 7.25));
|
||||
assert_eq!(t.plain_text(), "Votes: 50%");
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
|
||||
NodeId(
|
||||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
|
||||
@@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let style = edit::insertion_style(&t.runs, a, b);
|
||||
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
|
||||
// The inline character is one plain-text byte range: ruby over it follows the text.
|
||||
t.ruby_after_replace(a, b, vectorcraft_doc::text::INLINE_CHAR.len_utf8());
|
||||
refresh_bounds(t);
|
||||
Ok(caret)
|
||||
})?;
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{x?, y?, width?, height?, name?} (default: right of the last)",
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none)} (default place: right of the last)",
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
|
||||
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left, and a locked one is an error",
|
||||
has_doc,
|
||||
artboard_delete
|
||||
),
|
||||
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
"{index | artboards: [{index, name?, color?, locked?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Artwork Bounds",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art",
|
||||
has_doc,
|
||||
artboard_fit_art
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Selected Art",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes); a locked artboard stays as it is (an error when the fit would change it)",
|
||||
has_selection,
|
||||
artboard_fit_sel
|
||||
),
|
||||
@@ -289,7 +289,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
Some(i) if index(i) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
|
||||
},
|
||||
Value::String(s) => {
|
||||
@@ -781,7 +781,7 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "node.move";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = id_param(p, "parent");
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
|
||||
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !accepts(d, parent, n) {
|
||||
@@ -803,16 +803,27 @@ fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
|
||||
/// null (no color), else `Some(Some(color))`.
|
||||
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
|
||||
match p.get("color") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(v) => color_value(v).map(|c| Some(Some(c))).ok_or_else(|| bad(cmd, format!("bad color {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = &s.doc()?.doc;
|
||||
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
|
||||
let r = rect_from(p, def);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
let idx = s.edit("New Artboard", |d, _| {
|
||||
let id = d.next_artboard_id();
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
|
||||
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
|
||||
d.artboards.push(Artboard { id, name, rect: r, locked: false, show_center_mark: false, show_cross_hairs: false, color });
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": idx }))
|
||||
@@ -820,6 +831,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
|
||||
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
|
||||
s.edit("Delete Artboard", |d, sel| {
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
@@ -834,13 +846,15 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved,
|
||||
/// whether it was resized, the map its art and guides take when they go with it (`fit` when it
|
||||
/// scales them), and that art.
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color and lock, how
|
||||
/// far it moved, whether it was resized, the map its art and guides take when they go with it (`fit` when
|
||||
/// it scales them), and that art.
|
||||
struct ArtboardEdit<'a> {
|
||||
index: usize,
|
||||
now: Rect,
|
||||
name: Option<&'a str>,
|
||||
color: Option<Option<vectorcraft_color::Color>>,
|
||||
lock: Option<bool>,
|
||||
delta: vectorcraft_geom::Vec2,
|
||||
resized: bool,
|
||||
fit: Option<Affine>,
|
||||
@@ -872,7 +886,19 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("artboard.setProps", "an artboard is listed twice"));
|
||||
}
|
||||
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// The background color: a color sets it, null clears it, absent leaves it as it is.
|
||||
let color = artboard_color(q, "artboard.setProps")?;
|
||||
let now = rect_from(q, was);
|
||||
let lock = q.get("locked").and_then(Value::as_bool);
|
||||
// A locked artboard keeps its rectangle unless this unlocks it; its name can change.
|
||||
let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6);
|
||||
if reshaped && lock != Some(false) {
|
||||
super::panelcmds::refuse_locked(d, &[index])?;
|
||||
}
|
||||
// Values within 1e-6 pt of a locked artboard's (as Artboard Options passes them) leave its
|
||||
// rectangle exactly as it is.
|
||||
let held = lock != Some(false) && d.artboards.get(index).is_some_and(|a| a.locked);
|
||||
let now = if held { was } else { now };
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
|
||||
@@ -880,8 +906,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let delta = now.origin() - was.origin();
|
||||
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
|
||||
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
|
||||
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
|
||||
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
|
||||
let art = if transform.is_some() { d.art_moving_with(index, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
|
||||
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, lock, delta, resized, fit, transform, art });
|
||||
}
|
||||
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
|
||||
// the preference is then.
|
||||
@@ -900,6 +926,12 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
|
||||
}
|
||||
if let Some(c) = e.color {
|
||||
a.color = c;
|
||||
}
|
||||
if let Some(l) = e.lock {
|
||||
a.locked = l;
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = e.transform {
|
||||
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
|
||||
@@ -937,14 +969,19 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
||||
|
||||
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
|
||||
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
|
||||
/// template layers don't count. An artboard with no art on it stays as it is, and the command
|
||||
/// fails when none has art. The fits are one undo step named Artboard Options.
|
||||
/// template layers don't count. An artboard with no art on it, or a locked one, stays as it is,
|
||||
/// and the command fails when every one it names is locked or none has art. The fits are one undo
|
||||
/// step named Artboard Options.
|
||||
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let indices =
|
||||
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
|
||||
let unlocked: Vec<usize> = indices.iter().copied().filter(|i| d.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
|
||||
if unlocked.is_empty() {
|
||||
super::panelcmds::refuse_locked(d, &indices)?;
|
||||
}
|
||||
let mut fits = vec![];
|
||||
for i in indices {
|
||||
for i in unlocked {
|
||||
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
|
||||
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
|
||||
|
||||
@@ -932,16 +932,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
|
||||
/// already open (its own step then holds them).
|
||||
/// Run `f`'s commands as one undo step, or as part of the step of an undo group open in the active
|
||||
/// document, and undo them together when it fails ([`Session::begin_undo_scope`]).
|
||||
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
let open = s.active().is_some_and(|st| st.undo_group.is_some());
|
||||
if !open {
|
||||
s.begin_undo_group();
|
||||
}
|
||||
let scope = s.begin_undo_scope();
|
||||
let r = f(s);
|
||||
if !open {
|
||||
s.end_undo_group(r.is_err());
|
||||
if let Some(scope) = scope {
|
||||
s.end_undo_scope(scope, r.is_err());
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -1091,7 +1088,7 @@ fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, at) = s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
let g = lib.groups.remove(i);
|
||||
let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len());
|
||||
let at = index(to).min(lib.groups.len());
|
||||
let name = g.name.clone();
|
||||
lib.groups.insert(at, g);
|
||||
Ok((name, at))
|
||||
|
||||
@@ -411,6 +411,7 @@ const DEFAULT_DISTANCE: f64 = 10.0;
|
||||
|
||||
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
|
||||
check_numbers(p, &["value", "steps", "distance"], cmd)?;
|
||||
let smooth = switch_or(p, "smooth", false, cmd)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
|
||||
let mode = str_param(p, "spacing");
|
||||
@@ -428,7 +429,7 @@ fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpa
|
||||
steps("steps", n)?
|
||||
} else if let Some(d) = num("distance") {
|
||||
BlendSpacing::Distance(d)
|
||||
} else if bool_or(p, "smooth", false) {
|
||||
} else if smooth {
|
||||
BlendSpacing::SmoothColor
|
||||
} else {
|
||||
current
|
||||
@@ -643,7 +644,7 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("in") => Some(false),
|
||||
Some(_) => return Err(bad(C, "handle must be in or out")),
|
||||
};
|
||||
let independent = bool_or(p, "independent", false);
|
||||
let independent = switch_or(p, "independent", false, C)?;
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
|
||||
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
|
||||
if let Some(out) = handle {
|
||||
@@ -697,12 +698,12 @@ fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.reshapeSegment";
|
||||
let seg = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
let perpendicular = switch_or(p, "perpendicular", false, C)?;
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
|
||||
@@ -991,10 +992,11 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
|
||||
bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0);
|
||||
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
|
||||
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
|
||||
let given = switch_or(p, "horizontal", horizontal, cmd)?;
|
||||
horizontal = match str_param(p, "orientation") {
|
||||
Some("vertical") => false,
|
||||
Some("horizontal") => true,
|
||||
_ => bool_or(p, "horizontal", horizontal),
|
||||
_ => given,
|
||||
};
|
||||
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
|
||||
}
|
||||
@@ -1119,7 +1121,7 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.resetWithMesh";
|
||||
let maintain = bool_or(p, "maintainShape", true);
|
||||
let maintain = switch_or(p, "maintainShape", true, C)?;
|
||||
let kinds = reshaped(s, |env, kind| {
|
||||
let own = match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
@@ -1150,11 +1152,11 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
|
||||
None => cur.preserve_shape,
|
||||
};
|
||||
Ok(EnvelopeOptions {
|
||||
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
|
||||
anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?,
|
||||
preserve_shape,
|
||||
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
|
||||
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
|
||||
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
|
||||
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
|
||||
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
|
||||
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1249,6 +1251,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.options";
|
||||
check_numbers(p, &["fidelity"], C)?;
|
||||
check_numbers(p, WARP_NUMBERS, C)?;
|
||||
check_switches(p, &["horizontal"], C)?;
|
||||
let fid = p.get("fidelity").and_then(Value::as_f64);
|
||||
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
|
||||
return Err(bad(C, "fidelity must be 0..100"));
|
||||
@@ -1330,9 +1333,10 @@ fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.editContents";
|
||||
let envs = selected_of(s, is_envelope);
|
||||
let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. }));
|
||||
let want = bool_or(p, "editing", !cur);
|
||||
let want = switch_or(p, "editing", !cur, C)?;
|
||||
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
|
||||
let mut content_ids = vec![];
|
||||
for e in &envs {
|
||||
@@ -1535,6 +1539,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.setPointColor";
|
||||
check_numbers(p, &["opacity"], C)?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64);
|
||||
@@ -1573,6 +1578,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.addLine";
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
|
||||
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
|
||||
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rearrange All Artboards…",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}",
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
|
||||
has_doc,
|
||||
rearrange_artboards
|
||||
),
|
||||
@@ -348,11 +348,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7);
|
||||
let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7);
|
||||
let rot = f64_or(p, "rotate", 0.0);
|
||||
let rx = bool_or(p, "reflectX", false);
|
||||
let ry = bool_or(p, "reflectY", false);
|
||||
let random = bool_or(p, "random", false);
|
||||
let rx = switch_or(p, "reflectX", false, "object.transformEach")?;
|
||||
let ry = switch_or(p, "reflectY", false, "object.transformEach")?;
|
||||
let random = switch_or(p, "random", false, "object.transformEach")?;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let copy = switch_or(p, "copy", false, "object.transformEach")?;
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
@@ -598,7 +598,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
|
||||
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
|
||||
let gray = bool_or(p, "gray", false);
|
||||
let gray = switch_or(p, "gray", false, C)?;
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
@@ -613,7 +613,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9);
|
||||
let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9);
|
||||
let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9));
|
||||
let delete = bool_or(p, "deleteRaster", false);
|
||||
let delete = switch_or(p, "deleteRaster", false, C)?;
|
||||
let (gid, n) = s.edit("Create Object Mosaic", |d, sel| {
|
||||
let mut tiles = vec![];
|
||||
for r in 0..rows {
|
||||
@@ -670,7 +670,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(C, "select an image"))?;
|
||||
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||||
if p.get("trim").and_then(Value::as_bool) == Some(true) {
|
||||
if switch_or(p, "trim", false, C)? {
|
||||
return trim_image(s, id, (**im).clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
@@ -836,9 +836,9 @@ impl Session {
|
||||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.createTrimMarks";
|
||||
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
None => s.crop_mark_style(),
|
||||
let style = match p.get("style") {
|
||||
None | Some(Value::Null) => s.crop_mark_style(),
|
||||
Some(v) => v.as_str().and_then(MarkStyle::parse).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
};
|
||||
let d = TrimMarks::default();
|
||||
let marks = TrimMarks {
|
||||
@@ -849,7 +849,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
weight: f64_or(p, "weight", d.weight).max(0.01),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let rects: Vec<Rect> = if bool_or(p, "allArtboards", st.selection.is_empty()) {
|
||||
let rects: Vec<Rect> = if switch_or(p, "allArtboards", st.selection.is_empty(), C)? {
|
||||
st.doc.artboards.iter().map(|a| a.rect).collect()
|
||||
} else {
|
||||
st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect()
|
||||
@@ -899,7 +899,14 @@ pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
|
||||
}
|
||||
let rot = Affine::rotate(e0.y.atan2(e0.x));
|
||||
let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
|
||||
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
|
||||
return Some(LiveShape::Rectangle {
|
||||
w,
|
||||
h,
|
||||
radii: [0.0; 4],
|
||||
kinds: Default::default(),
|
||||
radii_relative: false,
|
||||
xf: Affine::translate(a[0].p.to_vec2()) * rot * flip,
|
||||
});
|
||||
}
|
||||
let b = path.bounds()?;
|
||||
let c = b.center();
|
||||
@@ -964,8 +971,10 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
id: aid,
|
||||
name: format!("Artboard {aid}"),
|
||||
rect: b,
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
});
|
||||
converted.push(id);
|
||||
}
|
||||
@@ -984,9 +993,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use super::newdoc::ArtboardLayout as L;
|
||||
// A value given (not null) must be one of the names.
|
||||
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
|
||||
let by_column = switch_or(p, "byColumn", false, CMD)?;
|
||||
let layout = match named("layout") {
|
||||
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
|
||||
None if bool_or(p, "byColumn", false) => L::GridByColumn,
|
||||
None if by_column => L::GridByColumn,
|
||||
None => L::GridByRow,
|
||||
};
|
||||
let rtl = match named("order") {
|
||||
@@ -1007,29 +1017,39 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let d = &s.doc()?.doc;
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||||
let first = rects.first().map_or(Point::ZERO, |r| r.origin());
|
||||
let origins = super::newdoc::grid_origins_rc(&sizes, first, (rows, cols), spacing, layout == L::GridByColumn, rtl);
|
||||
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
|
||||
// A locked artboard stays where it is, and keeps its place in the grid.
|
||||
let deltas: Vec<Vec2> =
|
||||
rects.iter().zip(origins).zip(&d.artboards).map(|((r, o), a)| if a.locked { Vec2::ZERO } else { o - r.origin() }).collect();
|
||||
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
|
||||
// its undo history stay as they are.
|
||||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||||
}
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
|
||||
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
|
||||
// hidden art moves only when prefs moveLockedWithArtboard is on.
|
||||
// With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the
|
||||
// artboard its center is on, with symbol instances measured by their art as
|
||||
// `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked
|
||||
// artboard, moves only when prefs moveLockedWithArtboard is on.
|
||||
fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() { objects(c, all, out) } else { out.push(c) }
|
||||
}
|
||||
}
|
||||
let locks = d.locked_artboards();
|
||||
let symbols = d.symbol_boxes();
|
||||
let art: Vec<(NodeId, Vec2)> = if move_art {
|
||||
d.layers
|
||||
.iter()
|
||||
.filter(|l| l.rides_with_artboard(locked_and_hidden))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||||
let mut tops = vec![];
|
||||
for l in d.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
|
||||
objects(l, locked_and_hidden, &mut tops);
|
||||
}
|
||||
tops.into_iter()
|
||||
.filter(|n| locked_and_hidden || !locks.holds(n))
|
||||
.filter_map(|n| {
|
||||
let c = n.geometric_bounds_with(&symbols)?.center();
|
||||
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
|
||||
|
||||
@@ -318,20 +318,42 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
|
||||
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
|
||||
}
|
||||
/// Error unless each of `keys` that `p` gives is a number (or null): an optional number read with
|
||||
/// [`f64_or`] would otherwise fall back to its default for a string such as `"60 mm"` (#1952).
|
||||
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
|
||||
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
|
||||
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
/// Error unless each of `keys` that `p` gives is a number (or null), as [`number_or`] reads them: an
|
||||
/// optional number read with [`f64_or`] would otherwise fall back to its default for a string such as
|
||||
/// `"60 mm"` (#1952).
|
||||
pub(crate) fn check_numbers(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
|
||||
for key in keys {
|
||||
match p.get(*key) {
|
||||
None | Some(Value::Null) | Some(Value::Number(_)) => {}
|
||||
Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
number_or(p, key, 0.0, cmd)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
|
||||
p.get(key).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
/// The true or false `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any
|
||||
/// other value, such as `"false"`, is an error: "`{key}` must be true or false".
|
||||
pub(crate) fn switch_or(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
/// Error unless each of `keys` that `p` gives is true or false (or null), as [`switch_or`] reads
|
||||
/// them.
|
||||
pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
|
||||
for key in keys {
|
||||
switch_or(p, key, false, cmd)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
p.get(key).and_then(Value::as_str)
|
||||
}
|
||||
@@ -341,6 +363,10 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
|
||||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
|
||||
}
|
||||
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
|
||||
pub(crate) fn index(v: u64) -> usize {
|
||||
usize::try_from(v).unwrap_or(usize::MAX)
|
||||
}
|
||||
/// Validate all explicit targets before a command changes the document or selection.
|
||||
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
|
||||
let values =
|
||||
@@ -486,6 +512,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
@@ -525,6 +552,9 @@ pub use flatten::FlattenerPreset;
|
||||
/// The shapes a group selected alone gives the Pathfinder panel.
|
||||
pub use pathops::shape_count;
|
||||
|
||||
/// The gaps View › Show Live Paint Gaps shows on a Live Paint group.
|
||||
pub use buildcmds::live_paint_gaps;
|
||||
|
||||
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
|
||||
/// They run as before but the command palette leaves them out (it lists the command once).
|
||||
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
|
||||
|
||||
@@ -338,8 +338,10 @@ impl DocSettings {
|
||||
id: i as u32 + 1,
|
||||
name: format!("Artboard {}", i + 1),
|
||||
rect,
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
|
||||
+336
-71
@@ -6,10 +6,11 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::corners::{convert_radii_relative, set_corners};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform};
|
||||
use super::opacitymask::percent;
|
||||
@@ -227,7 +228,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
|s, _| clip_edit(s, true)
|
||||
),
|
||||
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id?} (default: the selection's first object)", has_doc, isolate),
|
||||
cmd!(
|
||||
"object.isolate",
|
||||
"Enter Isolation Mode",
|
||||
[],
|
||||
None,
|
||||
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused",
|
||||
has_doc,
|
||||
isolate
|
||||
),
|
||||
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
|
||||
cmd!(
|
||||
"object.setProps",
|
||||
@@ -252,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
|
||||
can_distribute,
|
||||
distribute
|
||||
),
|
||||
@@ -261,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected; default: the selection; \"key\" spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the objects equal, in the order of their left (top) edges: the first object's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
|
||||
can_distribute,
|
||||
distribute_spacing
|
||||
),
|
||||
@@ -270,7 +279,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -280,7 +289,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
@@ -294,7 +303,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
|
||||
let st = s.doc()?;
|
||||
if !bool_or(p, "copy", false)
|
||||
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
|
||||
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return Ok(b.center());
|
||||
}
|
||||
@@ -305,10 +314,16 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
/// its `strokes` and `corners` params, else the preferences. The values join the command's journal
|
||||
/// entry, so replaying it scales the same way whatever the preferences are then.
|
||||
pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
|
||||
let sc = scaling_options(s, p);
|
||||
s.note_journal("strokes", json!(sc.strokes));
|
||||
s.note_journal("corners", json!(!sc.keep_corners));
|
||||
sc
|
||||
}
|
||||
|
||||
/// [`scaling`] without noting the values in the journal.
|
||||
fn scaling_options(s: &Session, p: &Value) -> Scaling {
|
||||
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
|
||||
let corners = bool_or(p, "corners", s.prefs.scale_corners);
|
||||
s.note_journal("strokes", json!(strokes));
|
||||
s.note_journal("corners", json!(corners));
|
||||
Scaling {
|
||||
strokes,
|
||||
effects: strokes.then_some(vectorcraft_render::effects::scale_effect),
|
||||
@@ -341,6 +356,11 @@ pub(crate) fn transforms_objects(p: &Value) -> bool {
|
||||
bool_or(p, "objects", true)
|
||||
}
|
||||
|
||||
/// The on/off params of the transform commands that [`apply_transform`], [`scaling`],
|
||||
/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file
|
||||
/// check them with [`check_switches`] before they change anything.
|
||||
const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"];
|
||||
|
||||
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
|
||||
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
|
||||
if objects {
|
||||
@@ -490,6 +510,8 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
|
||||
check_switches(p, &["anchors"], "object.transform")?;
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let (ids, anchors) = match ids_param(p, "ids") {
|
||||
Some(v) => (outermost(&s.doc()?.doc, v), false),
|
||||
@@ -500,12 +522,15 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["dx", "dy"], "object.move")?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.move")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
|
||||
}
|
||||
|
||||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["dx", "dy"], "object.nudge")?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?;
|
||||
check_switches(p, &["big"], "object.nudge")?;
|
||||
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
|
||||
let dx = f64_or(p, "dx", 0.0) * k;
|
||||
let dy = f64_or(p, "dy", 0.0) * k;
|
||||
@@ -534,6 +559,8 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], "object.rotate")?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?;
|
||||
check_switches(p, &["absolute"], "object.rotate")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let mut angle = f64_or(p, "angle", 0.0);
|
||||
@@ -547,6 +574,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["sy"], "object.scale")?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
|
||||
@@ -558,6 +586,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let m = match p.get("axis") {
|
||||
@@ -578,6 +607,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], "object.shear")?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
|
||||
@@ -590,6 +620,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform
|
||||
/// Each is repeated by running it again (its transform is per object, about each one's bounds).
|
||||
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?;
|
||||
if let Some(again) = s.doc()?.last_perspective.clone() {
|
||||
return super::distortcmds::transform_again(s, &again);
|
||||
}
|
||||
@@ -639,18 +670,24 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
None => by_parent.push((par, vec![*id])),
|
||||
}
|
||||
}
|
||||
for (par, group) in by_parent {
|
||||
for (par, mut group) in by_parent {
|
||||
// In a clip group or a clipping layer the bottom child is the clipping path: it stays
|
||||
// there, and the art above it never goes below it (#3188).
|
||||
let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips));
|
||||
if floor > 0 {
|
||||
group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor));
|
||||
}
|
||||
for (k, id) in group.iter().enumerate() {
|
||||
let Some((_, idx, len)) = d.position(*id) else { continue };
|
||||
if len == 0 {
|
||||
if len <= floor {
|
||||
continue;
|
||||
}
|
||||
let last = len - 1;
|
||||
let to = match how {
|
||||
Arrange::Front => last.saturating_sub(k),
|
||||
Arrange::Back => k.min(last),
|
||||
Arrange::Front => last.saturating_sub(k).max(floor),
|
||||
Arrange::Back => (floor + k).min(last),
|
||||
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
|
||||
Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)),
|
||||
Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))),
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
@@ -1047,6 +1084,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("object.isolate", "missing id"))?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
|
||||
// isolated: a double-click on it edits its text.
|
||||
@@ -1054,8 +1092,19 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("object.isolate", "type objects can't be isolated"));
|
||||
}
|
||||
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
|
||||
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
|
||||
// A Shaper Group isolates its source group, so that group is the one checked.
|
||||
if !st.doc.is_editable(isolation) {
|
||||
return Err(bad("object.isolate", "the object is hidden or locked"));
|
||||
}
|
||||
if guides_locked && crate::is_guide_path(&st.doc, isolation) {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
st.isolation = Some(isolation);
|
||||
st.selection.clear();
|
||||
// New art on a locked artboard, now deselected, is protected.
|
||||
st.sync_new_art();
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}
|
||||
@@ -1071,6 +1120,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
// The art on a locked artboard can't be selected.
|
||||
st.protect_locked_artboards(&[], &Default::default(), None);
|
||||
}
|
||||
st.revision += 1;
|
||||
ok()
|
||||
@@ -1103,6 +1154,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
|
||||
let ids = targets(s, p)?;
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
||||
let blend = match str_param(p, "blend") {
|
||||
@@ -1203,14 +1255,15 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
impl Session {
|
||||
/// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure:
|
||||
/// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds.
|
||||
/// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included,
|
||||
/// see [`effects::preview`]), else geometric bounds.
|
||||
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
|
||||
effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
|
||||
/// box, see [`Document::oriented_bounds`]).
|
||||
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
|
||||
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
|
||||
effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,9 +1299,9 @@ fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `ids` with their bounds (`preview`: visual bounds).
|
||||
/// `ids` with their bounds (`preview`: visual bounds as drawn, see [`effects::preview::bounds_of`]).
|
||||
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
ids.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position)
|
||||
@@ -1312,7 +1365,7 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
let st = s.doc()?;
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
|
||||
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
|
||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||
// of the selection, or the first one when no artboard is there.
|
||||
@@ -1322,9 +1375,9 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
}
|
||||
Some("key") => {
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k))
|
||||
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
|
||||
}
|
||||
_ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1353,6 +1406,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let (h, v) = direction(p, "object.align")?;
|
||||
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
||||
check_switches(p, &["patterns"], "object.align")?;
|
||||
if h.is_none() && v.is_none() {
|
||||
return Err(bad("object.align", "give horizontal or vertical"));
|
||||
}
|
||||
@@ -1467,10 +1521,68 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they
|
||||
/// may all be on one path).
|
||||
/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct
|
||||
/// Selection (they may all be on one path).
|
||||
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) }
|
||||
if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) {
|
||||
Ok(())
|
||||
} else {
|
||||
has_multi(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide
|
||||
/// across the axis (#1597), which has no size.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Spaced {
|
||||
Object(NodeId),
|
||||
Guide(usize),
|
||||
}
|
||||
|
||||
/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected
|
||||
/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds.
|
||||
/// Guides along the axis are left out, so they stay where they are.
|
||||
fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result<Vec<(Spaced, Rect)>> {
|
||||
let st = s.doc()?;
|
||||
let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r));
|
||||
let guides = st.selection.guides.iter().filter_map(|&i| {
|
||||
let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?;
|
||||
Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos)))
|
||||
});
|
||||
Ok(objects.chain(guides).collect())
|
||||
}
|
||||
|
||||
/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing:
|
||||
/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when
|
||||
/// aligning to the key (#2143).
|
||||
fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option<usize> {
|
||||
let key = match key_root(s, ids) {
|
||||
Some(id) => Spaced::Object(id),
|
||||
None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?,
|
||||
};
|
||||
items.iter().position(|(it, _)| *it == key)
|
||||
}
|
||||
|
||||
/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution),
|
||||
/// aligning to the key. It has no line there, so a spacing from it leaves everything where it
|
||||
/// is, as Align does.
|
||||
fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool {
|
||||
key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz)
|
||||
}
|
||||
|
||||
/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as
|
||||
/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position.
|
||||
fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result<Value> {
|
||||
let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) };
|
||||
let (mut moves, mut guides) = (vec![], vec![]);
|
||||
let d = &s.doc()?.doc;
|
||||
for &(it, delta) in deltas {
|
||||
match it {
|
||||
Spaced::Object(id) => moves.push((id, along(delta))),
|
||||
Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))),
|
||||
}
|
||||
}
|
||||
apply_moves(s, label, p, ids, &moves, &guides)
|
||||
}
|
||||
|
||||
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
|
||||
@@ -1513,6 +1625,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "object.distribute")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
|
||||
@@ -1524,7 +1637,6 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
|
||||
};
|
||||
}
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
|
||||
(Some(h), _) => (
|
||||
true,
|
||||
@@ -1544,44 +1656,50 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
|
||||
};
|
||||
// The selected guides across the axis are spaced with the objects (#1597). A guide has no
|
||||
// size, so its left edge, center and right edge are all its line.
|
||||
let mut items = spaced_items(s, &ids, preview, horiz)?;
|
||||
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
|
||||
let n = items.len();
|
||||
// With a spacing, the key object (or the compound path or selected group it is in) stays where
|
||||
// it is, and the lines are placed `spacing` apart, outward from its line, in their order.
|
||||
// Without a key object the spacing is not used. `from_key` is the key's index in `items` and
|
||||
// the spacing.
|
||||
let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g)));
|
||||
// Two objects are enough on an artboard, where they go to its edges, and when they are spaced
|
||||
// from a key object.
|
||||
if spacing.is_some() && key_guide_along(s, p, horiz) {
|
||||
return ok();
|
||||
}
|
||||
// With a spacing, the key object (or the compound path or selected group it is in) or the key
|
||||
// guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in
|
||||
// their order. Without a key the spacing is not used. `from_key` is the key's index in
|
||||
// `items` and the spacing.
|
||||
let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g));
|
||||
// Two items are enough on an artboard, where they go to its edges, and when they are spaced
|
||||
// from a key.
|
||||
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
|
||||
return ok();
|
||||
}
|
||||
let (first_box, last_box) = (&items[0].1, &items[n - 1].1);
|
||||
// `first` and `last` are the lines that the first and last objects end on. Without an artboard
|
||||
// the first and last objects stay where they are. On an artboard the first object moves until
|
||||
// its left (top) edge is on the artboard's left (top) edge, and the last object moves until its
|
||||
// `first` and `last` are the lines that the first and last items end on. Without an artboard
|
||||
// the first and last items stay where they are. On an artboard the first item moves until
|
||||
// its left (top) edge is on the artboard's left (top) edge, and the last item moves until its
|
||||
// right (bottom) edge is on the artboard's right (bottom) edge.
|
||||
let (first, last) = match board {
|
||||
Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1),
|
||||
Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1),
|
||||
None => (key(first_box, horiz), key(last_box, horiz)),
|
||||
};
|
||||
let moves: Vec<(NodeId, Vec2)> = items
|
||||
let deltas: Vec<(Spaced, f64)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, r))| {
|
||||
.map(|(i, (it, r))| {
|
||||
let target = match from_key {
|
||||
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
|
||||
None => first + (last - first) * i as f64 / (n - 1) as f64,
|
||||
};
|
||||
let delta = target - key(r, horiz);
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
(*it, target - key(r, horiz))
|
||||
})
|
||||
.collect();
|
||||
apply_moves(s, "Distribute", p, &ids, &moves, &[])
|
||||
apply_spaced(s, "Distribute", p, &ids, horiz, &deltas)
|
||||
}
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "object.distributeSpacing")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
@@ -1590,16 +1708,21 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
|
||||
}
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
// The selected guides across the axis are spaced with the objects, as items with no size (#1597).
|
||||
let mut items = spaced_items(s, &ids, preview, horiz)?;
|
||||
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
if spacing.is_some() && key_guide_along(s, p, horiz) {
|
||||
return ok();
|
||||
}
|
||||
let n = items.len();
|
||||
let &[(_, first), .., (_, last)] = items.as_slice() else {
|
||||
return Err(EngineError::Other("select two or more objects to distribute".into()));
|
||||
// Selected guides along the axis have nothing to space.
|
||||
return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() };
|
||||
};
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
|
||||
// The objects start at `lo`, and without a spacing the last one ends at `hi`. These are the
|
||||
// first object's left (top) edge and the last one's right (bottom) edge, or the artboard's
|
||||
// The items start at `lo`, and without a spacing the last one ends at `hi`. These are the
|
||||
// first item's left (top) edge and the last one's right (bottom) edge, or the artboard's
|
||||
// edges with Align to Artboard and no spacing.
|
||||
let (lo, hi) = match board.filter(|_| spacing.is_none()) {
|
||||
Some(r) if horiz => (r.x0, r.x1),
|
||||
@@ -1613,47 +1736,84 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut pos = lo;
|
||||
let mut deltas = vec![];
|
||||
for (id, r) in &items {
|
||||
deltas.push((*id, pos - start(r)));
|
||||
for (it, r) in &items {
|
||||
deltas.push((*it, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// With a spacing (aligning to a key object) the key object stays where it is, with the
|
||||
// compound path or selected group it is in: the others are spaced from it.
|
||||
let key = key_root(s, &ids);
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[])
|
||||
// With a spacing the key stays where it is: the key object, with the compound path or
|
||||
// selected group it is in, or the key guide (#2143). The others are spaced from it.
|
||||
let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1);
|
||||
let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect();
|
||||
apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas)
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match bounds_transform(s, p, selected_roots(s)?, 0.0)? {
|
||||
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
|
||||
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `object.setBounds` does with its values ([`bounds_transform`]).
|
||||
enum Bounds {
|
||||
/// With anchors selected with Direct Selection: the objects and the box of the anchors that
|
||||
/// [`set_anchor_bounds`] moves or scales.
|
||||
Anchors(Vec<NodeId>, Rect),
|
||||
/// The objects and the transform that gives them the values.
|
||||
Objects(Vec<NodeId>, Affine),
|
||||
}
|
||||
|
||||
/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the
|
||||
/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform
|
||||
/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds
|
||||
/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a
|
||||
/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and
|
||||
/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with
|
||||
/// Direct Selection and when the values are not valid.
|
||||
pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
|
||||
if cmd != "object.setBounds" {
|
||||
return None;
|
||||
}
|
||||
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None };
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
for k in ["x", "y", "width", "height", "reference", "proportional"] {
|
||||
p.remove(k);
|
||||
}
|
||||
p.insert("matrix".into(), json!(xf.as_coeffs()));
|
||||
Some(("object.transform".to_string(), Value::Object(p)))
|
||||
}
|
||||
|
||||
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
||||
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
||||
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
|
||||
check_switches(p, &["proportional"], "object.setBounds")?;
|
||||
if transforms_objects(p)
|
||||
&& !bool_or(p, "copy", false)
|
||||
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
|
||||
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return set_anchor_bounds(s, p, ids, b);
|
||||
return Ok(Bounds::Anchors(ids, b));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
// the math below runs in its frame.
|
||||
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
let g = frame.rect;
|
||||
// With Use Preview Bounds the values measure the visual box, whose margins around the
|
||||
// With Use Preview Bounds the values measure the visual box as drawn, whose margins around the
|
||||
// geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of
|
||||
// `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their
|
||||
// weight when an instance scales, so the rest of the margins stay.
|
||||
let (v, vs) = if s.prefs.use_preview_bounds {
|
||||
(d.oriented_bounds(&ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
|
||||
(effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
|
||||
} else {
|
||||
(g, g)
|
||||
};
|
||||
let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1];
|
||||
let (m, all) = (margins(vs), margins(v));
|
||||
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
let (w, h) = wanted_size(p, v);
|
||||
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
|
||||
@@ -1689,20 +1849,112 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
|
||||
return Ok(Bounds::Objects(ids, Affine::IDENTITY));
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
// The scales and the point above can miss the size or the reference point when the margins of
|
||||
// the objects differ, the members of groups and the objects in symbols' art included, and with
|
||||
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
|
||||
// by one ([`Spans::measure`]), and the scales solved for.
|
||||
let solved = if s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0 {
|
||||
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
|
||||
} else {
|
||||
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are
|
||||
// measured as drawn after each scale tried instead ([`refit_drawn`]).
|
||||
let symbols = d.symbol_boxes();
|
||||
let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n));
|
||||
let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) {
|
||||
None
|
||||
} else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() {
|
||||
let options = scaling_options(s, p);
|
||||
let to_local = frame.to_doc().inverse();
|
||||
let measure = |sx: f64, sy: f64| {
|
||||
let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy)));
|
||||
let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() };
|
||||
ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| {
|
||||
let mut n = n.clone();
|
||||
n.transform(xf, sc);
|
||||
vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local))
|
||||
})
|
||||
};
|
||||
// The scales above keep the effects' reach as a margin that stays, and give 0 for a size
|
||||
// smaller than that reach. Strokes and effects that scale shrink the reach with the art, so
|
||||
// the search then starts from the size wanted over the size as drawn.
|
||||
let seed = |s: f64, size: f64, now: f64| if s <= 0.0 && size > 0.0 && now > 1e-9 { size / now } else { s };
|
||||
refit_drawn(measure, refi, [w, h], (seed(sx, w, v.width()), seed(sy, h, v.height())), [g.width() > 1e-9, g.height() > 1e-9])
|
||||
} else {
|
||||
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
|
||||
};
|
||||
let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
|
||||
let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp));
|
||||
// A scale below `least` of art with a width (height) keeps `least`, and the reference point
|
||||
// moves with the scales as above.
|
||||
let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s };
|
||||
let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height()));
|
||||
let scale = about(rp, Affine::scale_non_uniform(kx, ky));
|
||||
let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) };
|
||||
let xf = frame.conjugate(Affine::translate(to - new_rp) * scale);
|
||||
Ok(Bounds::Objects(ids, xf))
|
||||
}
|
||||
|
||||
/// The scales along x and y that give the box `measure` returns for them the size `size`, and point
|
||||
/// `refi` of that box: the point that goes to the reference point's place. The search starts at
|
||||
/// `start`. Each step measures how the width and the height change with each scale that `free`
|
||||
/// marks true, by measuring again with that scale moved by 1e-4 of it (at least 1e-6), and moves
|
||||
/// those scales by the least-squares solution of that linear change, never below 0. The width can
|
||||
/// change with the y scale and the height with the x scale (a geometry effect can turn the art). A
|
||||
/// scale that `free` marks false stays as it is. The search stops when both sizes are within 1e-9
|
||||
/// of the sizes wanted (of the largest of 1 and those sizes), when no scale moves, when `measure`
|
||||
/// finds no box, or after 16 steps, and returns the scales that came nearest. `None` when `measure`
|
||||
/// finds no box for `start`.
|
||||
fn refit_drawn(
|
||||
measure: impl Fn(f64, f64) -> Option<Rect>,
|
||||
refi: usize,
|
||||
size: [f64; 2],
|
||||
start: (f64, f64),
|
||||
free: [bool; 2],
|
||||
) -> Option<(f64, f64, Point)> {
|
||||
let [w, h] = size;
|
||||
let tolerance = 1e-9 * w.max(h).max(1.0);
|
||||
let miss = |b: Rect| (b.width() - w).abs() + (b.height() - h).abs();
|
||||
let (mut sx, mut sy) = start;
|
||||
let mut b = measure(sx, sy)?;
|
||||
let mut best = (sx, sy, b);
|
||||
for _ in 0..16 {
|
||||
let f = [b.width() - w, b.height() - h];
|
||||
if f[0].abs() <= tolerance && f[1].abs() <= tolerance {
|
||||
break;
|
||||
}
|
||||
// How the width and the height change with the x scale (`a`) and the y scale (`c`).
|
||||
let change = |x: bool| -> Option<[f64; 2]> {
|
||||
let (on, s) = if x { (free[0], sx) } else { (free[1], sy) };
|
||||
if !on {
|
||||
return Some([0.0, 0.0]);
|
||||
}
|
||||
let ds = (s.abs() * 1e-4).max(1e-6);
|
||||
let m = if x { measure(sx + ds, sy) } else { measure(sx, sy + ds) }?;
|
||||
Some([(m.width() - b.width()) / ds, (m.height() - b.height()) / ds])
|
||||
};
|
||||
let (Some(a), Some(c)) = (change(true), change(false)) else { break };
|
||||
let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1];
|
||||
let (aa, ac, cc, af, cf) = (dot(a, a), dot(a, c), dot(c, c), dot(a, f), dot(c, f));
|
||||
let det = aa * cc - ac * ac;
|
||||
let (dx, dy) = if det > 1e-12 * aa * cc && det > 0.0 {
|
||||
((ac * cf - cc * af) / det, (ac * af - aa * cf) / det)
|
||||
} else if aa >= cc && aa > 0.0 {
|
||||
(-af / aa, 0.0)
|
||||
} else if cc > 0.0 {
|
||||
(0.0, -cf / cc)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
let (nx, ny) = ((sx + dx).max(0.0), (sy + dy).max(0.0));
|
||||
if !(nx.is_finite() && ny.is_finite()) || (nx == sx && ny == sy) {
|
||||
break;
|
||||
}
|
||||
let Some(next) = measure(nx, ny) else { break };
|
||||
(sx, sy, b) = (nx, ny, next);
|
||||
if miss(b) < miss(best.2) {
|
||||
best = (sx, sy, b);
|
||||
}
|
||||
}
|
||||
let (sx, sy, b) = best;
|
||||
Some((sx, sy, vectorcraft_geom::reference_point(b, refi)))
|
||||
}
|
||||
|
||||
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
|
||||
@@ -1724,7 +1976,7 @@ fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
|
||||
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
|
||||
/// the anchors, their handles with them, and those objects move or scale.
|
||||
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(b, refi);
|
||||
let (w, h) = wanted_size(p, b);
|
||||
// A single anchor, or anchors in a line, have no size to scale on that side.
|
||||
@@ -2069,6 +2321,7 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||||
}
|
||||
|
||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
|
||||
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
||||
let items = match p.get("items").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
@@ -2104,6 +2357,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
// A number that must be `what` (None when absent).
|
||||
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
|
||||
@@ -2148,6 +2402,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
|
||||
_ => {}
|
||||
}
|
||||
if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) {
|
||||
return false;
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
return true;
|
||||
}
|
||||
@@ -2156,9 +2413,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Setting corners folds a scale left in a rectangle's transform into its size, which
|
||||
// changes it.
|
||||
live.as_deref().is_none_or(|l| l.folded() == *l)
|
||||
&& picked
|
||||
.iter()
|
||||
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
|
||||
&& picked.iter().all(|k| {
|
||||
*k < c.base.anchor_count()
|
||||
&& radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9)
|
||||
&& kind.is_none_or(|kd| c.kind(*k) == kd)
|
||||
})
|
||||
};
|
||||
if ids.iter().all(has_values) {
|
||||
return ok();
|
||||
@@ -2192,6 +2451,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*path = l.to_path();
|
||||
}
|
||||
if let Some(on) = relative_radii
|
||||
&& let Some(l) = live.as_deref_mut()
|
||||
{
|
||||
convert_radii_relative(l, on);
|
||||
*path = l.to_path();
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
|
||||
use vectorcraft_doc::objects_on_path::{CENTER, Held, ObjectsOnPath, attachable_path, held, is_objects_on_path, place};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -168,7 +168,10 @@ fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
|
||||
if group_around(d, path).is_some() {
|
||||
return Some("the path is already in an Objects on Path group");
|
||||
}
|
||||
None
|
||||
match held(d, path)? {
|
||||
Held::Live => Some("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"),
|
||||
Held::Clipping => Some("a clipping path can't take objects"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
|
||||
@@ -179,6 +182,11 @@ fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
|
||||
if group_around(d, n.id).is_some() {
|
||||
return Some("an object already on a path can't be attached again");
|
||||
}
|
||||
match held(d, n.id) {
|
||||
Some(Held::Live) => return Some("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"),
|
||||
Some(Held::Clipping) => return Some("a clipping path can't be attached to a path"),
|
||||
None => {}
|
||||
}
|
||||
let mut graph = false;
|
||||
n.walk(&mut |c| graph |= c.extras().graph.is_some());
|
||||
graph.then_some("graphs can't be attached to a path")
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
|
||||
always,
|
||||
|s, p| {
|
||||
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = Paint::solid(s.last_solid);
|
||||
apply_to_proxy(s, p, paint)
|
||||
apply_to_proxy(s, p, paint, "paint.lastColor")
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -117,7 +117,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||||
apply_to_proxy(s, p, paint)
|
||||
apply_to_proxy(s, p, paint, "paint.lastGradient")
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -287,8 +287,15 @@ fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `stroke` param, defaulting to the proxy that is in front.
|
||||
fn stroke_param(s: &Session, p: &Value) -> bool {
|
||||
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
|
||||
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||||
switch_or(p, "stroke", !s.fill_active, cmd)
|
||||
}
|
||||
|
||||
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
|
||||
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
|
||||
/// `keepModel` calls it before it changes anything.
|
||||
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
|
||||
check_switches(p, &["keepModel"], cmd)
|
||||
}
|
||||
|
||||
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
|
||||
@@ -319,11 +326,13 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||||
|
||||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||||
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
|
||||
/// model ([`new_color_model`]), converted through the color settings.
|
||||
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
|
||||
/// other than true, false or null is an error.
|
||||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
check_keep_model(p, "paint")?;
|
||||
let model = new_color_model(s, p);
|
||||
let new_color = |c: Color| stored_color(model, c);
|
||||
if bool_or(p, "none", false) {
|
||||
if switch_or(p, "none", false, "paint")? {
|
||||
return Ok(Some(Paint::None));
|
||||
}
|
||||
if let Some(name) = str_param(p, "swatch") {
|
||||
@@ -391,13 +400,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
if p.get("tint").is_some() && p.get("swatch").is_none() {
|
||||
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
|
||||
}
|
||||
check_keep_model(p, cmd)?;
|
||||
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
|
||||
apply_paint(s, p, paint, fill)
|
||||
}
|
||||
|
||||
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
|
||||
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
let fill = !stroke_param(s, p);
|
||||
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
|
||||
let fill = !stroke_param(s, p, cmd)?;
|
||||
apply_paint(s, p, paint, fill)
|
||||
}
|
||||
|
||||
@@ -406,6 +416,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
/// remember the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
let focus = switch_or(p, "focus", true, cmd)?;
|
||||
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
|
||||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||||
if p.get("ids").is_none()
|
||||
@@ -431,7 +442,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(super::appearance::count_changed(d, &before, &[id]))
|
||||
})?;
|
||||
if bool_or(p, "focus", true) {
|
||||
if focus {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
@@ -514,7 +525,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
}
|
||||
s.paint.stroke = unplaced(&paint);
|
||||
}
|
||||
if bool_or(p, "focus", true) {
|
||||
if focus {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
@@ -666,7 +677,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// default when there are none) and remember the first target's new paint. The Appearance panel's
|
||||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let stroke = stroke_param(s, p);
|
||||
let stroke = stroke_param(s, p, cmd)?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => super::edit::selected_roots(s)?,
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, true)
|
||||
),
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.reorder";
|
||||
let i = artboard_index(&s.doc()?.doc, p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?;
|
||||
s.edit("Reorder Artboards", |d, _| {
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
@@ -114,21 +114,37 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// An error when one of artboards `indices` of `d` is locked: a locked artboard isn't moved,
|
||||
/// resized or deleted.
|
||||
pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> Result<()> {
|
||||
match indices.iter().any(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) {
|
||||
true => Err(EngineError::Other("the artboard is locked".into())),
|
||||
false => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut),
|
||||
/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard
|
||||
/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
|
||||
let all = s.prefs.move_locked_with_artboard;
|
||||
Ok(match (art, moving) {
|
||||
(false, _) => vec![],
|
||||
(true, false) => d.art_on_artboard(rect, all),
|
||||
(true, true) => d.art_moving_with(i, all),
|
||||
})
|
||||
}
|
||||
|
||||
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
|
||||
/// counted once, with the first of them it is on (artboards can overlap).
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut arts = vec![];
|
||||
for &i in indices {
|
||||
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
Ok(arts)
|
||||
@@ -144,7 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
/// their places relative to each other, together right of the last artboard.
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let arts = artboards_art(s, &indices, art, false)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
@@ -170,11 +186,14 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// a cut then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let arts = artboards_art(s, &indices, art, cut)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= indices.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
if cut {
|
||||
refuse_locked(&st.doc, &indices)?;
|
||||
}
|
||||
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
|
||||
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
|
||||
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
|
||||
@@ -215,11 +234,12 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
|
||||
Ok((index, copies))
|
||||
}
|
||||
|
||||
/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or `<name> copy`
|
||||
/// (`<name> copy 2`…) when that is taken → its index.
|
||||
/// Add a copy of artboard `src` at `rect`, unlocked, with an id of its own and its name, or
|
||||
/// `<name> copy` (`<name> copy 2`…) when that is taken → its index.
|
||||
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize {
|
||||
let mut a = src.clone();
|
||||
a.id = d.next_artboard_id();
|
||||
a.locked = false;
|
||||
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
|
||||
a.name = [src.name.clone(), format!("{} copy", src.name)]
|
||||
.into_iter()
|
||||
@@ -308,10 +328,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"tateChuYoko",
|
||||
"noBreak",
|
||||
"digits",
|
||||
"fontAxes",
|
||||
"burasagari",
|
||||
"romanHangingPunctuation",
|
||||
"kashida",
|
||||
"composer",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
@@ -372,6 +395,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
|
||||
}),
|
||||
};
|
||||
let kashida = match p.get("kashida") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("none") => vectorcraft_doc::text::Kashida::None,
|
||||
Some("short") => vectorcraft_doc::text::Kashida::Short,
|
||||
Some("medium") => vectorcraft_doc::text::Kashida::Medium,
|
||||
Some("long") => vectorcraft_doc::text::Kashida::Long,
|
||||
Some("stylistic") => vectorcraft_doc::text::Kashida::Stylistic,
|
||||
_ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")),
|
||||
}),
|
||||
};
|
||||
let spacing = |k: &str| spacing_param(p, k, C);
|
||||
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
|
||||
let single_word = match p.get("singleWordJustification") {
|
||||
@@ -432,6 +466,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
if let Some(v) = flag("tateChuYoko") {
|
||||
st.tate_chu_yoko = v;
|
||||
}
|
||||
if let Some(v) = flag("noBreak") {
|
||||
st.no_break = v;
|
||||
}
|
||||
if let Some(v) = &font_axes {
|
||||
super::textedit::change_font_axes(st, v);
|
||||
}
|
||||
@@ -521,6 +561,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("romanHangingPunctuation") {
|
||||
para.roman_hanging = v;
|
||||
}
|
||||
if let Some(v) = kashida {
|
||||
para.kashida = v;
|
||||
}
|
||||
if let Some(v) = composer {
|
||||
para.composer = v;
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let h_in = point_param(p, "in");
|
||||
let h_in = match p.get("in").filter(|h| !h.is_null()) {
|
||||
None => None,
|
||||
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
||||
};
|
||||
let independent = bool_or(p, "independent", false);
|
||||
s.edit("Close Path", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
@@ -178,7 +181,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
|
||||
// Objects selected whole move as objects, and their pattern tiles move with them when
|
||||
// `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||
let whole = s.doc()?.selection.whole();
|
||||
@@ -226,7 +229,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
let independent = bool_or(p, "independent", false);
|
||||
@@ -244,7 +247,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
s.edit("Move", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
@@ -725,6 +728,7 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
|
||||
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
|
||||
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
|
||||
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
|
||||
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let tol = f64_or(p, "tol", 3.0).max(0.0);
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
@@ -772,8 +776,8 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
|
||||
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
|
||||
@@ -381,6 +381,8 @@ fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
PathfinderOp::Crop => "Crop",
|
||||
PathfinderOp::Outline => "Outline",
|
||||
PathfinderOp::MinusBack => "Minus Back",
|
||||
PathfinderOp::HardMix => "Hard Mix",
|
||||
PathfinderOp::SoftMix => "Soft Mix",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +948,8 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for l in &d.layers {
|
||||
visit(l, false, false, false, &mut v, (stray, unpainted, empty_text));
|
||||
}
|
||||
v.retain(|id| d.is_editable(*id));
|
||||
let editable = d.editable();
|
||||
v.retain(|id| editable(*id));
|
||||
v
|
||||
};
|
||||
if found.is_empty() {
|
||||
@@ -991,13 +994,15 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
// Compound children are handled via their compound.
|
||||
targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. })));
|
||||
let editable = d.editable();
|
||||
targets.retain(|id| {
|
||||
*id != cutter_id
|
||||
&& d.is_editable(*id)
|
||||
&& editable(*id)
|
||||
&& d.is_visible(*id)
|
||||
&& d.index_path(*id).is_some_and(|ip| ip < cutter_pos)
|
||||
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
|
||||
});
|
||||
drop(editable);
|
||||
let mut out = vec![];
|
||||
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
|
||||
for t in targets {
|
||||
|
||||
@@ -366,7 +366,7 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = GridDefinition::default();
|
||||
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
|
||||
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let replace = switch_or(p, "replace", false, C)?;
|
||||
let mut imported = vec![];
|
||||
for mut preset in presets {
|
||||
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
|
||||
@@ -390,9 +390,13 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "imported": imported }))
|
||||
}
|
||||
|
||||
/// Toggle (or set, with `on`) a view option of the grid.
|
||||
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
|
||||
let want = p.get("on").and_then(Value::as_bool);
|
||||
/// Toggle (or set, with `on`) a view option of the grid for the command `cmd`.
|
||||
fn view_option(s: &mut Session, p: &Value, cmd: &str, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
|
||||
// A missing or null `on` toggles the option.
|
||||
let want = match p.get("on") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(switch_or(p, "on", false, cmd)?),
|
||||
};
|
||||
let mut g = silent(s, |g| {
|
||||
let f = field(g);
|
||||
*f = want.unwrap_or(!*f);
|
||||
@@ -401,19 +405,19 @@ fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &m
|
||||
}
|
||||
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.locked)
|
||||
view_option(s, p, "perspective.grid.lock", |g| &mut g.locked)
|
||||
}
|
||||
|
||||
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.lock_station)
|
||||
view_option(s, p, "perspective.grid.lockStation", |g| &mut g.lock_station)
|
||||
}
|
||||
|
||||
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.snap)
|
||||
view_option(s, p, "perspective.grid.snap", |g| &mut g.snap)
|
||||
}
|
||||
|
||||
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.rulers)
|
||||
view_option(s, p, "perspective.grid.rulers", |g| &mut g.rulers)
|
||||
}
|
||||
|
||||
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
//! the color.
|
||||
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
|
||||
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
|
||||
//! hanging indents, space before and after, and direction (`w:bidi`).
|
||||
//! hanging indents, space before and after, and direction (`w:bidi`). A paragraph justified with
|
||||
//! a low, medium or high kashida length (`lowKashida`, `mediumKashida`, `highKashida`) is
|
||||
//! justified with Short, Medium or Long kashidas; `both` is justified without kashidas.
|
||||
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
|
||||
//! style) and the styles it is based on, then the run's character style (else the default
|
||||
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
|
||||
@@ -37,6 +39,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use roxmltree::Node;
|
||||
use vectorcraft_doc::text::Kashida;
|
||||
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
|
||||
|
||||
use super::text::{self, Story};
|
||||
@@ -162,7 +165,7 @@ fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<
|
||||
|
||||
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
|
||||
/// CDATA sections, processing instructions, declarations and quoted attribute values.
|
||||
fn nesting(xml: &str) -> usize {
|
||||
pub(crate) fn nesting(xml: &str) -> usize {
|
||||
let b = xml.as_bytes();
|
||||
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
|
||||
// The index just past the first `end` at or after `from` (the end of the text if none).
|
||||
@@ -361,8 +364,9 @@ enum Align {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// Justified, the last line at the start (the right of a right-to-left paragraph).
|
||||
Both,
|
||||
/// Justified, the last line at the start (the right of a right-to-left paragraph), with the
|
||||
/// kashida length of `lowKashida`, `mediumKashida` and `highKashida` (§17.18.44).
|
||||
Both(Kashida),
|
||||
/// Justified, the last line too.
|
||||
Distribute,
|
||||
Start,
|
||||
@@ -860,8 +864,8 @@ impl Reader {
|
||||
Align::Left => Justify::Left,
|
||||
Align::Center => Justify::Center,
|
||||
Align::Right => Justify::Right,
|
||||
Align::Both if rtl => Justify::JustifyRight,
|
||||
Align::Both => Justify::JustifyLeft,
|
||||
Align::Both(_) if rtl => Justify::JustifyRight,
|
||||
Align::Both(_) => Justify::JustifyLeft,
|
||||
Align::Distribute => Justify::JustifyAll,
|
||||
Align::Start => Justify::Auto,
|
||||
Align::End if rtl => Justify::Left,
|
||||
@@ -873,6 +877,7 @@ impl Reader {
|
||||
space_before: points(p.before),
|
||||
space_after: points(p.after),
|
||||
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
|
||||
kashida: if let Align::Both(k) = p.align { k } else { Kashida::None },
|
||||
..ParaStyle::default()
|
||||
}
|
||||
})
|
||||
@@ -898,7 +903,10 @@ fn para_props(ppr: Node) -> ParaProps {
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
Some("right") => Some(Align::Right),
|
||||
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
|
||||
Some("both") => Some(Align::Both(Kashida::None)),
|
||||
Some("lowKashida") => Some(Align::Both(Kashida::Short)),
|
||||
Some("mediumKashida") => Some(Align::Both(Kashida::Medium)),
|
||||
Some("highKashida") => Some(Align::Both(Kashida::Long)),
|
||||
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
|
||||
Some("start") => Some(Align::Start),
|
||||
Some("end") => Some(Align::End),
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod docx;
|
||||
pub(crate) mod docx;
|
||||
mod rtf;
|
||||
mod text;
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -210,12 +210,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
let (mut doc, warnings, pdf) = match format.id {
|
||||
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
||||
// bounds, below).
|
||||
// bounds, below). A .ai saved without PDF compatibility is read from its editing data, as
|
||||
// it opens, and placed as an EPS is: the art on artboard `page`.
|
||||
"pdf" | "ai" | "ait" if pdf => {
|
||||
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
|
||||
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
|
||||
let mut read = fileio::page_document(&src.bytes, page - 1, &o);
|
||||
// Such a file can have more artboards than placeholder pages.
|
||||
if page > 1
|
||||
&& read.is_err()
|
||||
&& let Err(e) = fileio::page_document(&src.bytes, 0, &o)
|
||||
&& fileio::is_placeholder(&e)
|
||||
{
|
||||
read = Err(e);
|
||||
}
|
||||
let (mut doc, warnings, alone) =
|
||||
fileio::page_or_editing_data(src.name, &src.bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
|
||||
if alone {
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut doc, src.path, false);
|
||||
}
|
||||
(doc, warnings, !alone)
|
||||
}
|
||||
_ => {
|
||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||
@@ -233,7 +249,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
}
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
(l.doc, l.warnings, false)
|
||||
}
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
|
||||
@@ -228,6 +228,7 @@ pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocS
|
||||
t.set_paragraph_styles(story.paras);
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
t.system_layout = setup.system_layout;
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
t
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
|
||||
|
||||
use super::*;
|
||||
@@ -58,6 +59,10 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
|
||||
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
|
||||
}
|
||||
|
||||
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
|
||||
/// parameters and `_context`, and applies its output as one undo step ([`objects::apply_output`];
|
||||
/// effect records as [`admit_effects`] admits them). → `{plugin, ids}`: what it made or changed,
|
||||
/// which is then selected.
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.run";
|
||||
let plugin = plugin_by_id(C, p)?;
|
||||
@@ -85,13 +90,57 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?;
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
|
||||
let ids = objects::apply_output(d, &inputs, out, parent, &admit_effects).map_err(err)?;
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
|
||||
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
|
||||
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
|
||||
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
|
||||
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
|
||||
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
|
||||
/// host read one.
|
||||
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
|
||||
use vectorcraft_render::effects;
|
||||
const C: &str = "plugin.run";
|
||||
let mut out = Vec::with_capacity(fx.len());
|
||||
for (j, e) in fx.iter().enumerate() {
|
||||
if n.appearance.effects.contains(e) {
|
||||
out.push(e.clone());
|
||||
continue;
|
||||
}
|
||||
let refuse = |why: String| format!("[{j}] `{}`: {why}", e.id);
|
||||
let params = if e.params.is_null() { json!({}) } else { e.params.clone() };
|
||||
match effect::installed(&e.id) {
|
||||
Some(plugin) => {
|
||||
plugin.manifest().resolve_params(¶ms).map_err(|why| refuse(why.to_string()))?;
|
||||
}
|
||||
None => super::params::check_effect_params(C, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?,
|
||||
}
|
||||
if super::effectcmd::texture_load_effect(&e.id)
|
||||
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
|
||||
&& !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some())
|
||||
{
|
||||
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
|
||||
}
|
||||
let mut admitted = effects::new_effect(&e.id, ¶ms).ok_or_else(|| refuse("no such effect".into()))?;
|
||||
if effects::is_three_d(&e.id) {
|
||||
effects::validate_three_d(n, &e.id, &admitted.params).map_err(refuse)?;
|
||||
}
|
||||
admitted.visible = e.visible;
|
||||
out.push(admitted);
|
||||
}
|
||||
let items = n.appearance.items.iter().flat_map(|i| i.effects());
|
||||
if out.iter().chain(items).filter(|e| effects::is_three_d(&e.id)).count() > 1 {
|
||||
return Err(super::effectcmd::ONE_3D.into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
|
||||
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
|
||||
@@ -96,7 +96,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
|
||||
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
let i = v.as_u64().map(index).ok_or_else(|| bad(SET, "artboard must be a 0-based index"))?;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => region_holding(®ions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
|
||||
@@ -156,11 +156,11 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::<Vec<_>>();
|
||||
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64) {
|
||||
Some(n) if (n as usize) < units.len() => {
|
||||
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64).map(index) {
|
||||
Some(n) if n < units.len() => {
|
||||
let keys: Vec<Color> = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect();
|
||||
let weights: Vec<f32> = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect();
|
||||
cluster(&keys, &weights, n as usize).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect()
|
||||
cluster(&keys, &weights, n).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect()
|
||||
}
|
||||
_ => units,
|
||||
};
|
||||
|
||||
@@ -252,6 +252,18 @@ impl FolderStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create area folder `dir`, and the store's folder when it is missing. On Unix every folder
|
||||
/// made here is owner-only (`0o700`): the copies are unsaved documents, and with the default
|
||||
/// mode other accounts on the machine could read them. A store folder that already exists
|
||||
/// keeps its mode, since it can be one the user picked in Preferences.
|
||||
fn create_area(dir: &std::path::Path) -> std::result::Result<(), String> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
|
||||
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
@@ -285,7 +297,7 @@ impl RecoveryStore for FolderStore {
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
let f = self.file(name)?;
|
||||
if let Some(dir) = f.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
Self::create_area(dir)?;
|
||||
}
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
|
||||
}
|
||||
@@ -304,7 +316,7 @@ impl RecoveryStore for FolderStore {
|
||||
}
|
||||
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
|
||||
let dir = self.area(area)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
Self::create_area(&dir)?;
|
||||
let path = dir.join(LOCK_FILE);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
|
||||
@@ -266,7 +266,7 @@ fn all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index);
|
||||
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
@@ -644,7 +644,7 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let ap = &refn.appearance;
|
||||
let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item());
|
||||
let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item());
|
||||
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
|
||||
(Some(it), _) => Attribute::Item(it.clone()),
|
||||
(None, Some(e)) => Attribute::Effect(e.clone()),
|
||||
|
||||
@@ -511,10 +511,11 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul
|
||||
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let editable = st.doc.editable();
|
||||
let eligible: Vec<NodeId> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
st.doc.is_editable(*id)
|
||||
editable(*id)
|
||||
&& st.doc.node(*id).is_some_and(|n| {
|
||||
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
|
||||
&& (n.extras().shaper.is_some() || b::node_outline(n).is_some())
|
||||
@@ -691,10 +692,11 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// When `within` is given, only that group is considered.
|
||||
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
|
||||
let mut groups = vec![];
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_some()
|
||||
&& within.is_none_or(|g| g == n.id)
|
||||
&& d.is_editable(n.id)
|
||||
&& editable(n.id)
|
||||
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
|
||||
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
|
||||
{
|
||||
|
||||
@@ -409,7 +409,7 @@ fn splits(lo: f64, hi: f64, count: Option<u64>, size: Option<f64>) -> std::resul
|
||||
(None, Some(_)) => return Err("a slice size must be a positive number of points".into()),
|
||||
(None, None) => 1,
|
||||
};
|
||||
if n == 0 || n as usize > MAX_PIECES {
|
||||
if n == 0 || index(n) > MAX_PIECES {
|
||||
return Err(format!("divide into 1 to {MAX_PIECES} slices"));
|
||||
}
|
||||
let step = match (count, size) {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
};
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke};
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke, scale_layer};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -171,6 +171,16 @@ impl StrokeChange {
|
||||
self.apply(&mut st);
|
||||
style.set_stroke_layer(&st);
|
||||
}
|
||||
|
||||
/// [`Self::apply_char`] to type drawn at mean scale `k` (its `xf`'s): the weight and dash
|
||||
/// lengths are document-space, the character stroke is drawn in text space.
|
||||
pub(crate) fn apply_char_scaled(&self, style: &mut CharStyle, k: f64) {
|
||||
let mut st = style.stroke_layer();
|
||||
scale_layer(&mut st, k);
|
||||
self.apply(&mut st);
|
||||
scale_layer(&mut st, 1.0 / k);
|
||||
style.set_stroke_layer(&st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a Stroke panel edit find nothing selected (and no `ids`)? It then sets up new art.
|
||||
@@ -227,7 +237,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style));
|
||||
// Character strokes are drawn in text space, scaled by the type's transform.
|
||||
let k = Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0);
|
||||
t.runs.iter_mut().for_each(|r| change.apply_char_scaled(&mut r.style, k));
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none() && n.appearance.stroke().is_none() {
|
||||
|
||||
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user