mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 07:04:16 -04:00
Compare commits
861
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfa330a97 | ||
|
|
e99dea21ff | ||
|
|
85d5b10350 | ||
|
|
9cda8452ab | ||
|
|
d6d91c0954 | ||
|
|
e3e761fe32 | ||
|
|
81de0f6fbf | ||
|
|
4308821285 | ||
|
|
9471f92135 | ||
|
|
1c9edd9cca | ||
|
|
a3b2b18cb0 | ||
|
|
0f7156169b | ||
|
|
10a178f64a | ||
|
|
a4c21343bb | ||
|
|
8a48680ad2 | ||
|
|
dd2f5da98c | ||
|
|
19c7d61f8a | ||
|
|
9effb4e44e | ||
|
|
4bd55029e8 | ||
|
|
62776d5f46 | ||
|
|
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 | ||
|
|
ad0566943a | ||
|
|
9e7929ee89 | ||
|
|
2e4666a364 | ||
|
|
ee4564c586 | ||
|
|
c9d769ad4e | ||
|
|
62e8474caa | ||
|
|
301783c480 | ||
|
|
64699f5212 | ||
|
|
bea46e63ff | ||
|
|
c755909d67 | ||
|
|
30f372a220 | ||
|
|
24936cd006 | ||
|
|
1dda8d9e67 | ||
|
|
20557277c2 | ||
|
|
050f68a030 | ||
|
|
39fdaf7db8 | ||
|
|
2544d7f090 | ||
|
|
fd69aa0623 | ||
|
|
5dd86dc19c | ||
|
|
7617fcd536 | ||
|
|
c3083e245d | ||
|
|
0688ad04d7 | ||
|
|
89986d01bf | ||
|
|
4cd77a7a18 | ||
|
|
19efa9ea2b | ||
|
|
371d0de798 | ||
|
|
548ace24d2 | ||
|
|
a13968e821 | ||
|
|
e2e2ac12a9 | ||
|
|
61f35cd037 | ||
|
|
891793b3a4 | ||
|
|
6b24e3cd6a | ||
|
|
02e12c6935 | ||
|
|
f664564e97 | ||
|
|
d14b67e015 | ||
|
|
5251246778 | ||
|
|
1097ab190d | ||
|
|
17ba27a0f6 | ||
|
|
4322c825a5 | ||
|
|
ddab0152af | ||
|
|
f12ad3aa0a | ||
|
|
699e6e9af4 | ||
|
|
059b6d083c | ||
|
|
7852f363a1 | ||
|
|
263c36567f | ||
|
|
398d2e19de | ||
|
|
0567752448 | ||
|
|
38e73060a7 | ||
|
|
6bed87b5fb | ||
|
|
6f3c974984 | ||
|
|
cade497b79 | ||
|
|
feda1c062f | ||
|
|
b929561fc3 | ||
|
|
4be63905b1 | ||
|
|
8a7a1cf199 | ||
|
|
b560b97616 | ||
|
|
d21f3e0667 | ||
|
|
ac7f0eebf8 | ||
|
|
498d17f72e | ||
|
|
326b1c3139 | ||
|
|
2ff683d5fe | ||
|
|
fd780c51b0 | ||
|
|
1b6552a4fa | ||
|
|
8918747a24 | ||
|
|
ef99948cd5 | ||
|
|
04139ad0b6 | ||
|
|
0f855be77f | ||
|
|
7c47692b32 | ||
|
|
8a6e4e77f3 | ||
|
|
686da2dce7 | ||
|
|
68b1f7a07c | ||
|
|
2ceb183bfe | ||
|
|
fe6ef8ade7 | ||
|
|
05017111ba | ||
|
|
5397ad22e7 | ||
|
|
a5ec99d515 | ||
|
|
92c1657334 | ||
|
|
8f481aa2f4 | ||
|
|
7616bb9221 | ||
|
|
1f579352ed | ||
|
|
851eb7f076 | ||
|
|
a2c764d312 | ||
|
|
917aa29dd5 | ||
|
|
11adb72d44 | ||
|
|
52380d683d | ||
|
|
1f2007eb46 | ||
|
|
19e3144d27 | ||
|
|
cfea906681 | ||
|
|
fbc654734f | ||
|
|
ac83e91c5f | ||
|
|
10e59b0d13 | ||
|
|
dc79640956 | ||
|
|
e2b2c410d9 | ||
|
|
30646c83bd | ||
|
|
361ebd29bf | ||
|
|
39daf465f5 | ||
|
|
d8b0dcfc04 | ||
|
|
d5cd54c9b8 | ||
|
|
443ffd6c6f | ||
|
|
73a3347bbb | ||
|
|
5c32d527f1 | ||
|
|
1abe1961ca | ||
|
|
f922a635cb | ||
|
|
91fde1d7fa | ||
|
|
6a8d52804b | ||
|
|
8521e0a505 | ||
|
|
e96771d5ea | ||
|
|
223bfd29c2 | ||
|
|
12f7d41c38 | ||
|
|
85163a7c82 | ||
|
|
1c1fd57b33 | ||
|
|
88a444ef7b | ||
|
|
0c62d9c02c | ||
|
|
cb229ac08c | ||
|
|
b8f9c403a7 | ||
|
|
fd53416de3 | ||
|
|
d507a73d09 | ||
|
|
9435c5079d | ||
|
|
99aa4e6a7c | ||
|
|
0ee6b1ac68 | ||
|
|
b21b3edb6c | ||
|
|
88bd9bcd9e | ||
|
|
6ef9f2dd93 | ||
|
|
6d713de980 | ||
|
|
1eac4f5b79 | ||
|
|
a2aac6bead | ||
|
|
222ec72e28 | ||
|
|
8f7486b90f | ||
|
|
5c5684f4c5 | ||
|
|
19adc36964 | ||
|
|
f412d30adf | ||
|
|
7bf326eaf1 | ||
|
|
bf021ae08b | ||
|
|
301727c7d5 | ||
|
|
a52198cefa | ||
|
|
926ca62952 | ||
|
|
d3ee9646de | ||
|
|
a446d76637 | ||
|
|
81cf399bfa | ||
|
|
d8c20c8eb4 | ||
|
|
f106f32af4 | ||
|
|
acd8ca097d | ||
|
|
f9e559156f | ||
|
|
c2a605ce8e | ||
|
|
ec4975f237 | ||
|
|
cb631ae635 | ||
|
|
5087186eac | ||
|
|
84ab208c43 | ||
|
|
ae617db6d3 | ||
|
|
408081000d | ||
|
|
34a9a48340 | ||
|
|
6de7c0a441 | ||
|
|
b48290a4f9 | ||
|
|
7833756f6e | ||
|
|
ce998b89bc | ||
|
|
db92c91678 | ||
|
|
ddc5fda173 | ||
|
|
bb32072be9 | ||
|
|
d41af02637 | ||
|
|
62910d0327 | ||
|
|
db9fa8e0a9 | ||
|
|
6d7a062227 | ||
|
|
4b8e42e517 | ||
|
|
5bf159d5d1 | ||
|
|
a48e65ba68 | ||
|
|
f557a1b6f3 | ||
|
|
5af05ab572 | ||
|
|
197cf0ba67 | ||
|
|
0734836616 | ||
|
|
de8492b90c | ||
|
|
e975942be0 | ||
|
|
2f93923e60 | ||
|
|
68a99c359b | ||
|
|
1a1b0dfd59 | ||
|
|
472a6d5359 | ||
|
|
819da290f1 | ||
|
|
303c5f8bca | ||
|
|
de985a46e6 | ||
|
|
035f1cb2f6 | ||
|
|
e52b07105d | ||
|
|
00f1231334 | ||
|
|
c58c642f07 | ||
|
|
39b59b5730 | ||
|
|
2c6a2ad082 | ||
|
|
b09e23f4a1 | ||
|
|
1e914c8d82 | ||
|
|
45d9a409d4 | ||
|
|
9d4675e7d4 | ||
|
|
d5dfbf4bc3 | ||
|
|
af48dda322 | ||
|
|
b87cb157ed | ||
|
|
d2b927b57b | ||
|
|
04750ece1b | ||
|
|
c08955320c | ||
|
|
742a5c1af2 | ||
|
|
1f80c28662 | ||
|
|
6fed871f88 | ||
|
|
3033fb9985 | ||
|
|
7e1095f9b1 | ||
|
|
f378c5c836 | ||
|
|
55acd88d42 | ||
|
|
319221c087 | ||
|
|
9d43326ad7 | ||
|
|
8baf5b1343 | ||
|
|
a60f8d7b9f | ||
|
|
2c52bed0b5 | ||
|
|
de3dd27fda | ||
|
|
575bd0f924 | ||
|
|
1ef66c48c1 | ||
|
|
35b5895183 | ||
|
|
0bd2a40128 | ||
|
|
605597b09c | ||
|
|
646563b033 | ||
|
|
2861c113d6 | ||
|
|
48a0206adb | ||
|
|
1fc3c23096 | ||
|
|
7dc0c034d8 | ||
|
|
be7fd07562 | ||
|
|
007f536c79 | ||
|
|
515569470c | ||
|
|
c07945b934 | ||
|
|
de4c6b1a6d | ||
|
|
ecaae7ee46 | ||
|
|
7bc7025a62 | ||
|
|
8d123b19ca | ||
|
|
4aa4b0e70b | ||
|
|
4614c08476 | ||
|
|
2ecc488111 | ||
|
|
6ed171c1b4 | ||
|
|
3eed3be8e6 | ||
|
|
85af04a83a | ||
|
|
bdd5470dcf | ||
|
|
157679c1a8 | ||
|
|
320bffb8ad | ||
|
|
3d23fc5175 | ||
|
|
8c6085beca | ||
|
|
6cb075b926 | ||
|
|
0f27503f5b | ||
|
|
92ca32e55d | ||
|
|
37ee8bee3c | ||
|
|
219253f163 | ||
|
|
e6c8ce1759 | ||
|
|
e1761eb489 | ||
|
|
7f01abd687 | ||
|
|
729582dc78 | ||
|
|
7c64ecd998 | ||
|
|
55f98095f3 | ||
|
|
ac1b380b40 | ||
|
|
c3a6aa0a29 | ||
|
|
1d214956f9 | ||
|
|
32a322531a | ||
|
|
0d09d6a188 | ||
|
|
73eaa84d34 | ||
|
|
923a3ddc09 | ||
|
|
a466625c89 | ||
|
|
73f921fa0a | ||
|
|
fb18533e12 | ||
|
|
56be31fe9b | ||
|
|
a23d7a827f | ||
|
|
1c80b23745 | ||
|
|
c9a2491ef7 | ||
|
|
2ae992870b | ||
|
|
cec31c5f4a | ||
|
|
3b527ca339 | ||
|
|
4f18f1c860 | ||
|
|
b529f356cc | ||
|
|
048875cc4e | ||
|
|
57ddb71d97 | ||
|
|
49f7f5d7d8 | ||
|
|
aa9c70a7b1 | ||
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
0d2eeed995 | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
bb14031aa3 | ||
|
|
eec304dde1 | ||
|
|
b934451b6d | ||
|
|
5906f53ca5 | ||
|
|
411f5f7680 | ||
|
|
63cebd392a | ||
|
|
3041b6a672 | ||
|
|
090659b78b | ||
|
|
640c87b537 | ||
|
|
bf2379f401 | ||
|
|
561db27fef | ||
|
|
cc1a496947 | ||
|
|
48ef70e873 | ||
|
|
b58100c2f2 | ||
|
|
37b90eb050 | ||
|
|
7cf8dd4764 | ||
|
|
4547439176 | ||
|
|
797e899c56 | ||
|
|
6516e8611c | ||
|
|
e342c2843a | ||
|
|
52b6543c2e | ||
|
|
1e3ab306d4 | ||
|
|
1b4c421bc4 | ||
|
|
73f40e3fd3 | ||
|
|
049739b85e | ||
|
|
dcad0878c5 | ||
|
|
08c21446b4 | ||
|
|
d4a68dd73f | ||
|
|
45ca05e7b2 | ||
|
|
14d1667a7e | ||
|
|
5a2510576f | ||
|
|
47b5e886f8 | ||
|
|
650e938b68 | ||
|
|
36d751b72f | ||
|
|
c40718fafa | ||
|
|
d9d1a1aba3 | ||
|
|
2c4f059444 | ||
|
|
dba668240c | ||
|
|
a0657c9a22 | ||
|
|
5f20ef194b | ||
|
|
eb8eaa7aa0 | ||
|
|
1d6daaefcc | ||
|
|
78b7ffe1fb | ||
|
|
31df7aee9a | ||
|
|
2909972140 | ||
|
|
cfe77f1706 | ||
|
|
69dad83148 | ||
|
|
5646fa7253 | ||
|
|
545e935e0c | ||
|
|
46a1d9f175 | ||
|
|
f794069fbd | ||
|
|
c2bd5b4a52 | ||
|
|
2623e95a13 | ||
|
|
ced933af47 | ||
|
|
c29701f65b | ||
|
|
44e7be8b04 | ||
|
|
2c93a290e8 | ||
|
|
646a5cc9a3 | ||
|
|
20c8296b5a | ||
|
|
eb5bf7b828 | ||
|
|
36b85472d2 | ||
|
|
e69a08c1a6 | ||
|
|
602b3b34bc | ||
|
|
9361a3b3f1 | ||
|
|
dfb1a86a23 | ||
|
|
3f673c35eb | ||
|
|
2d2698e5fb | ||
|
|
0842941f8d | ||
|
|
df1bf3fe32 | ||
|
|
dbb6b470b3 | ||
|
|
9735518c84 | ||
|
|
cf5caa43d5 | ||
|
|
ed69938e0e | ||
|
|
ec410552cf | ||
|
|
a275496ef4 | ||
|
|
5851d8b1ad | ||
|
|
d8b0ce1013 | ||
|
|
282ed8955b | ||
|
|
034ef916ad | ||
|
|
138e9ca466 | ||
|
|
1caa957531 | ||
|
|
4803573940 | ||
|
|
3c0c112fbd | ||
|
|
aa9cf83fd2 | ||
|
|
24d4777d1a | ||
|
|
739b0b7335 | ||
|
|
468d7b21e9 | ||
|
|
db3092ae2e | ||
|
|
0cf12a1611 | ||
|
|
d23030ce2b | ||
|
|
2643f57b1f | ||
|
|
ef9e554dd2 | ||
|
|
438d27ffbe | ||
|
|
7d56260b47 | ||
|
|
22ce516e2d | ||
|
|
30e54dedcf | ||
|
|
e461b8b818 | ||
|
|
496288a023 | ||
|
|
bb7d6b487d | ||
|
|
dd5d421a9c | ||
|
|
5df664d1a7 | ||
|
|
b27b5b80ea | ||
|
|
2fc47948d5 | ||
|
|
df7daff3e4 | ||
|
|
030729d6f3 | ||
|
|
c0e6d8eb02 | ||
|
|
d7e79448d7 | ||
|
|
d66077a15f | ||
|
|
4eef9b7e8c | ||
|
|
aafe102649 | ||
|
|
ffc12babf2 | ||
|
|
830cefcaf6 | ||
|
|
bdf6059155 | ||
|
|
c73945d244 | ||
|
|
904f0fe522 | ||
|
|
5e6810655f | ||
|
|
299a4b36ad | ||
|
|
6a83f6fea6 | ||
|
|
e6aec0664c | ||
|
|
d44dc983e6 | ||
|
|
39f9204d57 | ||
|
|
82e363c73b | ||
|
|
b05043c759 | ||
|
|
ef5084f48b | ||
|
|
1ddfb56f66 | ||
|
|
b46ffd0059 |
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]"
|
||||
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/affinity/fuzz"
|
||||
- "/crates/plugins/example"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+295
-302
File diff suppressed because it is too large.
Load diff
+12
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,7 +13,11 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
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" }
|
||||
@@ -21,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" }
|
||||
@@ -31,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" }
|
||||
@@ -64,7 +71,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -92,3 +92,34 @@ Assets already bundled through Hayro's default features retain their licences:
|
||||
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -11,20 +11,23 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</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">
|
||||
@@ -147,7 +150,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -163,6 +166,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Quick start
|
||||
|
||||
For posters and sewing patterns, see [Print a large poster across multiple pages](docs/print-tiling.md).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
@@ -178,6 +183,8 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
|
||||
```
|
||||
|
||||
For text and custom letter shapes, see [Typography and Custom Lettering](docs/typography-lettering.md).
|
||||
|
||||
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
@@ -224,9 +231,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -238,22 +244,22 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- 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);
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern 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.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -372,8 +378,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+19
-7
@@ -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-18 · **Change:** minor (about 40 community PRs of 2026-10-18: 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 33/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) | 49% | 21–35 |
|
||||
| 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 |
|
||||
@@ -112,8 +112,8 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -123,18 +123,26 @@ 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: 24 filters and the Effect Gallery, 21–35 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-18 | About 40 community PRs landed in one batch 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). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 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). |
|
||||
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
@@ -163,6 +171,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
@@ -180,9 +189,12 @@ 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 |
|
||||
| 2026-10-17 | minor | Progress log row for the Background Export progress dialog (#2564); headline numbers unchanged. |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | 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-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers 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 }
|
||||
|
||||
@@ -78,22 +78,27 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
||||
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
|
||||
@@ -259,6 +264,10 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let template = is_template(&output);
|
||||
if template && (artboard.is_some() || range.is_some()) {
|
||||
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
|
||||
}
|
||||
let mut h = Headless::new();
|
||||
let opened = if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
@@ -266,16 +275,23 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let (command, mut params) = if template {
|
||||
// Save as Template writes every artboard, with the .ai options.
|
||||
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
("document.export", params)
|
||||
};
|
||||
for (key, value) in legacy {
|
||||
params[key] = json!(value);
|
||||
}
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
let mut r = h.call("engine.execute", json!({"command": command, "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
@@ -285,6 +301,21 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `path` a `.ait` template? `file.saveAsTemplate` writes it; exports don't.
|
||||
fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -371,7 +402,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
r
|
||||
}
|
||||
Step::Export(path) => {
|
||||
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -179,3 +179,28 @@ fn convert_writes_legacy_ai_with_its_options() {
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
/// convert and `run --export` write a `.ait` as Save as Template does: a PDF-compatible template of
|
||||
/// every artboard that opens as a new untitled document.
|
||||
#[test]
|
||||
fn convert_and_run_write_ait_templates() {
|
||||
let pdf = tmp("template-in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let (converted, exported) = (tmp("converted.ait"), tmp("exported.ait"));
|
||||
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &converted])).unwrap();
|
||||
assert_eq!((&r["format"], &r["path"]), (&Value::from("ait"), &Value::from(converted.as_str())), "{r}");
|
||||
let r: Value = serde_json::from_str(ok(&["run", "--in", &pdf, "--export", &exported]).lines().nth(1).unwrap()).unwrap();
|
||||
assert_eq!(r["result"]["format"], "ait", "{r}");
|
||||
for ait in [&converted, &exported] {
|
||||
assert!(std::fs::read(ait).unwrap().starts_with(b"%PDF"), "{ait}");
|
||||
let opened: Value = serde_json::from_str(ok(&["run", "--in", ait]).lines().next().unwrap()).unwrap();
|
||||
let opened = &opened["result"];
|
||||
assert_eq!((&opened["format"], &opened["restored"]), (&Value::from("ait"), &Value::from(true)), "{opened}");
|
||||
assert!(opened["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{opened}");
|
||||
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
|
||||
}
|
||||
// A template holds every artboard: one can't be picked.
|
||||
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
|
||||
assert!(!one.status.success());
|
||||
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -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 }
|
||||
@@ -72,10 +75,23 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
|
||||
pollster = "1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# GNOME/Wayland needs client-side window controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
# through winit and rfd.
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
|
||||
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
|
||||
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
|
||||
# exactly: review the diff before bumping.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
@@ -84,3 +100,5 @@ winresource = "0.1"
|
||||
[dev-dependencies]
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
|
||||
zbus = "=5.19.0"
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+251
-31
@@ -43,6 +43,8 @@ mod mac_screen_color;
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
@@ -142,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);
|
||||
@@ -249,10 +247,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
|
||||
let mut value: Value = serde_json::from_slice(bytes).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
recover(object, "docking_hidden");
|
||||
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
|
||||
for workspace in workspaces {
|
||||
if let Some(object) = workspace.as_object_mut() {
|
||||
recover(object, "dockingHidden");
|
||||
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
|
||||
for key in ["custom_workspaces", "workspace_changes"] {
|
||||
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
|
||||
for workspace in workspaces {
|
||||
if let Some(object) = workspace.as_object_mut() {
|
||||
recover(object, "dockingHidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,7 +292,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;
|
||||
}
|
||||
@@ -305,7 +306,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 {
|
||||
@@ -331,10 +332,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,
|
||||
@@ -359,10 +359,20 @@ fn make_folder(request: &PickRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
|
||||
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
|
||||
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
|
||||
/// the portal can't.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
#[cfg_attr(windows, allow(unused_variables))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
|
||||
make_folder(request);
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
if let PickRequest::Save(pick) = request
|
||||
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
@@ -375,7 +385,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
show_dialog(file_dialog(&request, parent), &request, parent)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
@@ -389,6 +399,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -430,11 +441,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
let parent = parent.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
let _ = tx.send(show_dialog(dialog, &request, &parent));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
@@ -559,8 +571,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
|
||||
/// no Windows file name has) is refused rather than split into other arguments.
|
||||
fn explorer_safe(path: &str) -> Result<(), String> {
|
||||
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
|
||||
fn open_file(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
@@ -608,13 +627,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 {
|
||||
@@ -989,6 +1068,7 @@ mod tests {
|
||||
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
|
||||
app.ui.custom_workspaces =
|
||||
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
|
||||
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
|
||||
let original = serde_json::to_value(&app.ui).unwrap();
|
||||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||||
assert!(valid_location.is_object());
|
||||
@@ -999,6 +1079,8 @@ mod tests {
|
||||
raw["docking_hidden"] = invalid_hidden.clone();
|
||||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||||
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
|
||||
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
|
||||
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
|
||||
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
|
||||
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
|
||||
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
|
||||
@@ -1015,6 +1097,8 @@ mod tests {
|
||||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||||
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
|
||||
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
|
||||
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
|
||||
assert!(loaded.workspace_changes[0].docking.is_none());
|
||||
}
|
||||
}
|
||||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
@@ -1027,17 +1111,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.
|
||||
@@ -1069,4 +1177,116 @@ mod tests {
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
|
||||
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
|
||||
/// declares.
|
||||
const LINUX_TYPES: &[(&str, &str)] = &[
|
||||
("vectorcraft", "application/x-vectorcraft"),
|
||||
("drawcraft", "application/x-vectorcraft"),
|
||||
("svg", "image/svg+xml"),
|
||||
("svgz", "image/svg+xml-compressed"),
|
||||
("pdf", "application/pdf"),
|
||||
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
|
||||
("ait", "application/x-ai-template"),
|
||||
("png", "image/png"),
|
||||
("jpg", "image/jpeg"),
|
||||
("jpeg", "image/jpeg"),
|
||||
("gif", "image/gif"),
|
||||
("webp", "image/webp"),
|
||||
("tif", "image/tiff"),
|
||||
("tiff", "image/tiff"),
|
||||
("bmp", "image/bmp"),
|
||||
("vctemplate", "application/x-vectorcraft-template"),
|
||||
("dxf", "image/vnd.dxf"),
|
||||
("emf", "image/emf"),
|
||||
("wmf", "image/wmf"),
|
||||
("eps", "image/x-eps"),
|
||||
("af", "application/x-affinity"),
|
||||
("afdesign", "application/x-affinity"),
|
||||
("afpub", "application/x-affinity"),
|
||||
];
|
||||
|
||||
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
|
||||
/// other one.
|
||||
const LINUX_SHARED_TYPES: &[&str] = &[
|
||||
"image/svg+xml",
|
||||
"image/svg+xml-compressed",
|
||||
"application/pdf",
|
||||
"application/illustrator", // brand-ok: shared-mime-info's type
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/vnd.dxf",
|
||||
"image/emf",
|
||||
"image/wmf",
|
||||
"image/x-eps",
|
||||
];
|
||||
|
||||
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
|
||||
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
|
||||
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
|
||||
"af",
|
||||
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
|
||||
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
|
||||
)];
|
||||
|
||||
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
|
||||
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
|
||||
/// have.
|
||||
#[test]
|
||||
fn the_linux_desktop_entry_opens_every_readable_format() {
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
|
||||
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
|
||||
let listed: Vec<&str> =
|
||||
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
|
||||
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
|
||||
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
|
||||
let declared: Vec<(&str, Vec<&str>, String)> = mime
|
||||
.split("<mime-type type=\"")
|
||||
.skip(1)
|
||||
.map(|block| {
|
||||
let body = block.split("</mime-type>").next().unwrap_or_default();
|
||||
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
|
||||
let magic = match (body.find("<magic"), body.find("</magic>")) {
|
||||
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(block.split('"').next().unwrap_or_default(), globs, magic)
|
||||
})
|
||||
.collect();
|
||||
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
|
||||
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
|
||||
associated.sort_unstable();
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
|
||||
for (e, t) in LINUX_TYPES {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
|
||||
let package = declared.iter().find(|(name, ..)| name == t);
|
||||
if LINUX_SHARED_TYPES.contains(t) {
|
||||
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
|
||||
continue;
|
||||
}
|
||||
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
|
||||
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
|
||||
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
|
||||
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
|
||||
} else {
|
||||
assert!(globs.contains(e), "{t} has no *.{e} glob");
|
||||
}
|
||||
}
|
||||
for t in &listed {
|
||||
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
|
||||
}
|
||||
for (t, globs, _) in &declared {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
|
||||
for g in globs {
|
||||
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
|
||||
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
|
||||
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
|
||||
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
|
||||
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
|
||||
//! user picks another file type, so the type picked was lost. This call sends the first file type as
|
||||
//! `current_filter`, reads the one picked back, and answers its first extension after the path
|
||||
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
|
||||
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
|
||||
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
|
||||
//!
|
||||
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
|
||||
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::FilePick;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
/// A file type in the dialog: its label and its extensions without the dot.
|
||||
type Filter = (String, Vec<String>);
|
||||
|
||||
/// A save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct SaveRequest {
|
||||
/// The portal's identifier of the parent window, or empty.
|
||||
parent: String,
|
||||
/// The suggested name; empty for none.
|
||||
name: String,
|
||||
folder: Option<String>,
|
||||
/// The file types, labeled in the interface's language; the dialog starts on the first.
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
impl SaveRequest {
|
||||
/// The dialog for `pick` over the window `parent` names.
|
||||
fn new(pick: &FilePick, parent: String) -> Self {
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let filters = pick
|
||||
.filters
|
||||
.iter()
|
||||
.map(|(label, exts)| {
|
||||
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
|
||||
})
|
||||
.collect();
|
||||
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog answered: the path, as typed, and the label of the file type picked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Saved {
|
||||
path: String,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
|
||||
/// every file), as rfd sends them.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options for `request`, answering on `token`.
|
||||
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o = HashMap::from([("handle_token", Value::from(token))]);
|
||||
if !request.name.is_empty() {
|
||||
o.insert("current_name", Value::from(request.name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &request.folder {
|
||||
// A byte string ending in NUL, as the portal specifies.
|
||||
let mut bytes = folder.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if let Some(first) = request.filters.first() {
|
||||
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
o.insert("current_filter", Value::from(wire(first)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
|
||||
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
|
||||
/// some other way), as rfd reads it.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
if code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or("no file in the portal's answer")?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
|
||||
/// or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<String> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(rest.len());
|
||||
let mut rest = rest.as_bytes();
|
||||
while let Some((&b, tail)) = rest.split_first() {
|
||||
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
|
||||
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
Some(decoded) => {
|
||||
out.push(decoded);
|
||||
rest = tail.get(2..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push(b);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
|
||||
Some(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
|
||||
/// the file type picked, found by its label. Empty when the dialog was canceled.
|
||||
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
|
||||
let Some(saved) = saved else { return Vec::new() };
|
||||
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
|
||||
std::iter::once(saved.path).chain(picked).collect()
|
||||
}
|
||||
|
||||
/// The portal's identifier of an X11 window.
|
||||
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
|
||||
format!("x11:{window:x}")
|
||||
}
|
||||
|
||||
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
|
||||
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
|
||||
/// show the dialog; rfd shows it then.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
|
||||
if pick.filters.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The export stays until the dialog has answered: the handle is valid while it lives.
|
||||
let (parent, _export) = window.map(parent).unwrap_or_default();
|
||||
let request = SaveRequest::new(pick, parent);
|
||||
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
|
||||
match saved {
|
||||
Ok(saved) => Some(pick_answer(saved, &request)),
|
||||
Err(e) => {
|
||||
log::info!("the portal's save dialog failed ({e}): rfd shows it");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use zbus::blocking::Proxy;
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
// Listen before asking, so that an answer that comes at once is not missed.
|
||||
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
// The title is empty, as rfd sends it for a save.
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
|
||||
// returns.
|
||||
let reply = if handle.as_str() == path {
|
||||
replies.next()
|
||||
} else {
|
||||
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
|
||||
}
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window` when it is an X11 window.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn x11_window(window: &winit::window::Window) -> Option<String> {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
match window.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
|
||||
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
|
||||
/// the window has neither an X11 id nor a Wayland handle.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
|
||||
if let Some(id) = x11_window(window) {
|
||||
return (id, None);
|
||||
}
|
||||
match wayland::export(window) {
|
||||
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
|
||||
None => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
|
||||
/// Linux).
|
||||
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
|
||||
(x11_window(window).unwrap_or_default(), None)
|
||||
}
|
||||
|
||||
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
|
||||
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
|
||||
/// makes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::protocol::wl_surface::WlSurface;
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
|
||||
|
||||
/// A window exported for the portal: `handle` names it until this is dropped.
|
||||
pub struct Export {
|
||||
pub handle: String,
|
||||
exported: ZxdgExportedV2,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Drop for Export {
|
||||
fn drop(&mut self) {
|
||||
self.exported.destroy();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the export waits for.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
handle: Option<String>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for State {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExporterV2, ()> for State {
|
||||
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExportedV2, ()> for State {
|
||||
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
|
||||
if let zxdg_exported_v2::Event::Handle { handle } = event {
|
||||
state.handle = Some(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
|
||||
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
|
||||
pub fn export(window: &winit::window::Window) -> Option<Export> {
|
||||
let (conn, surface) = surface(window)?;
|
||||
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||
let qh = queue.handle();
|
||||
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
|
||||
let exported = exporter.export_toplevel(&surface, &qh, ());
|
||||
let mut state = State::default();
|
||||
let answered = queue.roundtrip(&mut state);
|
||||
exporter.destroy();
|
||||
match (answered, state.handle) {
|
||||
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
|
||||
_ => {
|
||||
exported.destroy();
|
||||
let _ = conn.flush();
|
||||
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `window`'s Wayland connection and surface, from its raw handles.
|
||||
#[allow(unsafe_code)]
|
||||
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
|
||||
use wayland_backend::sys::client::{Backend, ObjectId};
|
||||
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
|
||||
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
|
||||
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
|
||||
// holds it until the export is dropped); a backend made from a foreign display does not
|
||||
// close it when dropped.
|
||||
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
|
||||
let surface = WlSurface::from_id(&conn, id).ok()?;
|
||||
Some((conn, surface))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request() -> SaveRequest {
|
||||
let pick = FilePick {
|
||||
name: "Untitled-1.vectorcraft".into(),
|
||||
folder: Some("/home/me/Art".into()),
|
||||
filters: vec![
|
||||
FilePick::filter("VectorCraft", &["vectorcraft"]),
|
||||
FilePick::filter("SVG", &["svg"]),
|
||||
FilePick::filter("PDF-compatible .ai", &["ai"]),
|
||||
],
|
||||
};
|
||||
SaveRequest::new(&pick, "x11:3a0000f".into())
|
||||
}
|
||||
|
||||
/// The dialog starts on the first file type (the document's format) and shows the suggested
|
||||
/// name in its folder.
|
||||
#[test]
|
||||
fn the_request_names_the_file_type_the_dialog_starts_on() {
|
||||
let request = request();
|
||||
let o = options(&request, "t1");
|
||||
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
|
||||
let filters = vec![
|
||||
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
|
||||
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
|
||||
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
|
||||
];
|
||||
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
|
||||
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
|
||||
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
|
||||
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
|
||||
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
|
||||
let o = options(&bare, "t2");
|
||||
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
|
||||
}
|
||||
|
||||
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
|
||||
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
|
||||
let mut r = HashMap::new();
|
||||
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
|
||||
if let Some(label) = filter {
|
||||
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
|
||||
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The file type picked comes back with the path as typed, and its first extension follows
|
||||
/// the path in the answer.
|
||||
#[test]
|
||||
fn the_answer_carries_the_file_type_picked() {
|
||||
let request = request();
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
|
||||
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
// A portal that names no file type, or one the dialog did not offer: the path alone.
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
|
||||
}
|
||||
|
||||
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
|
||||
/// is an error, and rfd shows the dialog instead.
|
||||
#[test]
|
||||
fn a_canceled_dialog_answers_no_file() {
|
||||
let request = request();
|
||||
for code in [1, 2] {
|
||||
assert_eq!(answer(code, &HashMap::new()), Ok(None));
|
||||
assert!(pick_answer(None, &request).is_empty());
|
||||
}
|
||||
assert!(answer(0, &HashMap::new()).is_err());
|
||||
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
|
||||
// A `%` without two hex digits after it stays as it is.
|
||||
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
|
||||
assert_eq!(file_uri_path("file://host/x.svg"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_by_their_id() {
|
||||
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
|
||||
/// the label of the file type "picked".
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
|
||||
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
|
||||
/// is no `dbus-daemon` to run.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
let mut daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server =
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let seen = Seen::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let calls = log.lock().map(|mut l| {
|
||||
l.push((text("current_name"), filter));
|
||||
l.len()
|
||||
});
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if own_paths {
|
||||
// As a person answers the dialog: after the caller listened on the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
results.insert("uris", Value::from(vec![uri.to_string()]));
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
|
||||
/// name, and the answer names the file and the type picked; Cancel answers no file.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
|
||||
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
let saved = save_file(&conn, &request).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
assert_eq!(save_file(&conn, &request), Ok(None));
|
||||
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
|
||||
/// there.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
|
||||
#[test]
|
||||
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
|
||||
let request = request();
|
||||
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let expected: Vec<&str> =
|
||||
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
|
||||
assert_eq!(labels, expected);
|
||||
assert_eq!(request.parent, "x11:3a0000f");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="10" height="10" fill="currentColor" fill-opacity="0.4"/><rect x="10" y="10" width="10" height="10"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 7h10"/><circle cx="6" cy="12" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 12h8"/><circle cx="6" cy="17" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 17h11"/></svg>
|
||||
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 460 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -127,5 +127,30 @@ sync_github_name = true
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MiQ71]
|
||||
real_name = "Mirko Kerezovic"
|
||||
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.mockydevs]
|
||||
real_name = "Don Omondi"
|
||||
display_name = "Don Omondi"
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
+32
-7
@@ -287,6 +287,8 @@ pub struct ArtBrush {
|
||||
pub width: f64,
|
||||
pub flip_along: bool,
|
||||
pub flip_across: bool,
|
||||
/// When true, art is placed on each run between sharp corners so folds do not overlap.
|
||||
pub overlap: bool,
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
@@ -304,6 +306,7 @@ impl Default for ArtBrush {
|
||||
width: 100.0,
|
||||
flip_along: false,
|
||||
flip_across: false,
|
||||
overlap: false,
|
||||
colorization: Colorization::Tints,
|
||||
}
|
||||
}
|
||||
@@ -410,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 {
|
||||
@@ -453,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) {
|
||||
@@ -514,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.
|
||||
|
||||
@@ -152,6 +152,28 @@ fn art_brush_between_guides_keeps_ends() {
|
||||
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_brush_overlap_splits_at_corners() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((100.0, 0.0));
|
||||
bp.line_to((100.0, 100.0));
|
||||
let brush = |overlap: bool| Brush {
|
||||
name: "a".into(),
|
||||
kind: BrushKind::Art(ArtBrush {
|
||||
art: rect_art(10.0, 4.0),
|
||||
scale: ArtScale::Stretch,
|
||||
overlap,
|
||||
colorization: Colorization::None,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0));
|
||||
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0));
|
||||
assert_eq!(one.len(), 1, "one bent copy along the whole path");
|
||||
assert_eq!(two.len(), 2, "one copy per run between corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_brush_follows_curves() {
|
||||
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
|
||||
@@ -286,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);
|
||||
@@ -535,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]
|
||||
|
||||
+51
-21
@@ -154,35 +154,65 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
}
|
||||
|
||||
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
|
||||
/// corner (the whole path when it has none).
|
||||
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
|
||||
let l = t.len();
|
||||
let corners = t.corner_positions();
|
||||
if corners.is_empty() {
|
||||
return vec![(0.0, l)];
|
||||
}
|
||||
if t.closed {
|
||||
let first = corners[0].1;
|
||||
let mut bounds = corners.iter().map(|c| c.1).collect::<Vec<_>>();
|
||||
bounds.push(first + l);
|
||||
return bounds.windows(2).map(|w| (w[0], w[1])).collect();
|
||||
}
|
||||
let mut bounds = vec![0.0];
|
||||
bounds.extend(corners.iter().map(|c| c.1));
|
||||
bounds.push(l);
|
||||
bounds.windows(2).map(|w| (w[0], w[1])).collect()
|
||||
}
|
||||
|
||||
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
|
||||
let l = s1 - s0;
|
||||
if l <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let w = b.width();
|
||||
let (along, cross) = match a.scale {
|
||||
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, s0, s1), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
bend(t, art, b, along, cross)
|
||||
}
|
||||
|
||||
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
|
||||
let cross = weight * a.width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
if t.len() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let w = b.width();
|
||||
let (along, cross) = match a.scale {
|
||||
ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, 0.0, l), k * a.width / 100.0)
|
||||
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
|
||||
for (s0, s1) in runs {
|
||||
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
|
||||
out.push(n);
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, 0.0, l), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(n) = bend(&t, &art, b, along, cross) {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
+151
-1
@@ -56,6 +56,84 @@ struct Sub {
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
|
||||
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
|
||||
/// relative tolerance rather than exact control-point tests.
|
||||
struct CircularSub {
|
||||
centre: Point,
|
||||
radius: f64,
|
||||
samples: Vec<Point>,
|
||||
}
|
||||
|
||||
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
|
||||
let u = 1.0 - t;
|
||||
let a = u * u * u;
|
||||
let b = 3.0 * u * u * t;
|
||||
let c = 3.0 * u * t * t;
|
||||
let d = t * t * t;
|
||||
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
|
||||
}
|
||||
|
||||
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
|
||||
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
|
||||
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
|
||||
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
|
||||
return None;
|
||||
}
|
||||
let mut samples = vec![sub.start];
|
||||
let mut from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for i in 1..=8 {
|
||||
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
if samples.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut anchors = vec![sub.start];
|
||||
anchors.extend(sub.segs.iter().map(|s| s.end()));
|
||||
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
anchors.pop();
|
||||
}
|
||||
let (p0, p1, p2) = if anchors.len() >= 3 {
|
||||
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
|
||||
} else {
|
||||
(samples[0], samples[samples.len() / 2], *samples.last()?)
|
||||
};
|
||||
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
|
||||
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
|
||||
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let q0 = p0.x * p0.x + p0.y * p0.y;
|
||||
let q1 = p1.x * p1.x + p1.y * p1.y;
|
||||
let q2 = p2.x * p2.x + p2.y * p2.y;
|
||||
let centre = Point::new(
|
||||
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
|
||||
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
|
||||
);
|
||||
let radius = centre.distance(p0);
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (radius * 5e-4).max(1e-6);
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
fn angle_deg(p: Point, centre: Point) -> f64 {
|
||||
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
|
||||
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
|
||||
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
|
||||
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
|
||||
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
|
||||
// its width. Non-circular curves continue through the existing fallback.
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
|
||||
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
|
||||
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
|
||||
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
|
||||
let geometrically_closed = last.distance(sub.start) <= 1e-9;
|
||||
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
|
||||
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
|
||||
// before fitting, updating extents or allocating an entity handle.
|
||||
if sub.closed != geometrically_closed {
|
||||
return false;
|
||||
}
|
||||
let Some(circle) = circular_subpath(sub) else { return false };
|
||||
let centre = self.to_dxf * circle.centre;
|
||||
let radius = circle.radius * self.k.abs();
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count sampled points in the drawing extents. For a full circle include all cardinal
|
||||
// points exactly; for an arc, the dense samples conservatively cover its visible span.
|
||||
if sub.closed {
|
||||
let r = circle.radius;
|
||||
for p in [
|
||||
Point::new(circle.centre.x - r, circle.centre.y),
|
||||
Point::new(circle.centre.x + r, circle.centre.y),
|
||||
Point::new(circle.centre.x, circle.centre.y - r),
|
||||
Point::new(circle.centre.x, circle.centre.y + r),
|
||||
] {
|
||||
self.pt(p);
|
||||
}
|
||||
} else {
|
||||
for p in &circle.samples {
|
||||
self.pt(*p);
|
||||
}
|
||||
}
|
||||
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
if sub.closed {
|
||||
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
return true;
|
||||
}
|
||||
|
||||
let start = self.to_dxf * sub.start;
|
||||
let end = self.to_dxf * last;
|
||||
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
|
||||
let mut a0 = angle_deg(start, centre);
|
||||
let mut a1 = angle_deg(end, centre);
|
||||
let am = angle_deg(middle, centre);
|
||||
if !on_ccw_arc(a0, a1, am) {
|
||||
std::mem::swap(&mut a0, &mut a1);
|
||||
}
|
||||
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbArc");
|
||||
}
|
||||
self.e.num(50, a0);
|
||||
self.e.num(51, a1);
|
||||
true
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
@@ -713,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);
|
||||
|
||||
+94
-14
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(
|
||||
kinds(&out),
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
|
||||
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
|
||||
"the fill's outline and hatch, then the stroke, then the circle's stroke"
|
||||
);
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
|
||||
let hatch = &e[1];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[3];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// The circular ellipse is preserved as a native circle (#1299).
|
||||
let circle = &e[3];
|
||||
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
|
||||
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
|
||||
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||||
let d = doc_with(|d| {
|
||||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||||
let c = Point::new(120.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
vec![
|
||||
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
]
|
||||
});
|
||||
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
|
||||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
|
||||
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||||
}
|
||||
|
||||
// An actual ellipse is not close enough to a circle and remains a spline.
|
||||
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_stroked_circles_keep_polyline_width() {
|
||||
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "POLYLINE");
|
||||
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
|
||||
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||||
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||||
arc.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
// Each fill and stroke: its outlines, then its hatch.
|
||||
let fill = ["LWPOLYLINE", "HATCH"];
|
||||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
@@ -397,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);
|
||||
@@ -451,7 +531,7 @@ fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||||
}
|
||||
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = 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;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cms::{self, Model};
|
||||
use crate::{Color, Gradient, GradientStop};
|
||||
|
||||
/// The spread a new point gets.
|
||||
@@ -157,11 +158,12 @@ impl Freeform {
|
||||
f
|
||||
}
|
||||
|
||||
/// Colour the points along `g`, first to last.
|
||||
/// Color the points along `g`, first to last, mixing neighboring stops in the model they share
|
||||
/// ([`Color::lerp_keep_model`]).
|
||||
pub fn recolor(&mut self, g: &Gradient) {
|
||||
let n = self.points.len();
|
||||
for (k, p) in self.points.iter_mut().enumerate() {
|
||||
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
|
||||
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +232,10 @@ impl Freeform {
|
||||
best
|
||||
}
|
||||
|
||||
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
|
||||
/// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as
|
||||
/// [`Self::model_field`] mixes them.
|
||||
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
|
||||
let ([r, g, b], a) = self.field(scale).sample(p);
|
||||
(Color::rgb(r, g, b), a)
|
||||
self.model_field(scale).sample(p)
|
||||
}
|
||||
|
||||
/// The colour field, with colours converted and lines tessellated once (for sampling many
|
||||
@@ -265,6 +267,32 @@ impl Freeform {
|
||||
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
|
||||
}
|
||||
|
||||
/// [`Self::field`] mixing the colors in the model every point shares instead of display RGB:
|
||||
/// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink,
|
||||
/// or the RGB or Lab components. Points in different models mix in display RGB, as
|
||||
/// [`Self::field`] mixes them, and the field gives that color in the working RGB space.
|
||||
pub fn model_field(&self, scale: f64) -> ModelField {
|
||||
let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } };
|
||||
let parts = |c: &Color| match *c {
|
||||
Color::Rgb { r, g, b } => [r, g, b, 0.0],
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
Color::Lab { l, a, b } => [l, a, b, 0.0],
|
||||
};
|
||||
let first = self.field_with(scale, &|c| {
|
||||
let [x, y, z, _] = parts(c);
|
||||
[x, y, z]
|
||||
});
|
||||
let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0]));
|
||||
ModelField { model: Some(model), first, k }
|
||||
}
|
||||
|
||||
/// The color model every point is in (`None` without points or when their models differ).
|
||||
pub fn shared_model(&self) -> Option<Model> {
|
||||
let m = self.points.first()?.color.model();
|
||||
self.points.iter().all(|p| p.color.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Add a point; returns its index.
|
||||
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
|
||||
self.points.push(p);
|
||||
@@ -324,8 +352,9 @@ impl Freeform {
|
||||
true
|
||||
}
|
||||
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
|
||||
/// opacity and spread between the segment's ends. Returns the new point's index.
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color
|
||||
/// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the
|
||||
/// segment's ends. Returns the new point's index.
|
||||
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
|
||||
let c = *self.segments(line).get(segment)?;
|
||||
let l = &self.lines[line];
|
||||
@@ -334,7 +363,7 @@ impl Freeform {
|
||||
let u = t as f32;
|
||||
let p = FreeformPoint {
|
||||
at: c.eval(t),
|
||||
color: a.color.lerp(&b.color, u),
|
||||
color: a.color.lerp_keep_model(&b.color, u),
|
||||
opacity: a.opacity + (b.opacity - a.opacity) * u,
|
||||
spread: a.spread + (b.spread - a.spread) * u,
|
||||
};
|
||||
@@ -440,6 +469,41 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
model: Option<Model>,
|
||||
/// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the
|
||||
/// models differ), or the Gray ink as the first of the three values.
|
||||
first: Field,
|
||||
/// The K ink of a CMYK field.
|
||||
k: Option<Field>,
|
||||
}
|
||||
|
||||
impl ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
pub fn model(&self) -> Option<Model> {
|
||||
self.model
|
||||
}
|
||||
|
||||
/// The color and opacity at `p`: in the points' shared model, else in the working RGB space.
|
||||
pub fn sample(&self, p: Point) -> (Color, f32) {
|
||||
let ([x, y, z], a) = self.first.sample(p);
|
||||
let c = match self.model {
|
||||
None => {
|
||||
let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]);
|
||||
Color::rgb(r, g, b)
|
||||
}
|
||||
Some(Model::Rgb) => Color::rgb(x, y, z),
|
||||
Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])),
|
||||
Some(Model::Gray) => Color::gray(x),
|
||||
Some(Model::Lab) => Color::lab(x, y, z),
|
||||
};
|
||||
(c, a)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -477,6 +541,31 @@ mod tests {
|
||||
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_mixes_in_the_model_the_points_share() {
|
||||
let halfway = |a: Color, b: Color| {
|
||||
let mut f = two(0.0);
|
||||
(f.points[0].color, f.points[1].color) = (a, b);
|
||||
f.sample(Point::new(50.0, 0.0), 50.0).0
|
||||
};
|
||||
let near = |a: Color, b: Color| match (a, b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => {
|
||||
[c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5)
|
||||
}
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5,
|
||||
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4),
|
||||
_ => false,
|
||||
};
|
||||
let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}");
|
||||
let c = halfway(Color::gray(0.2), Color::gray(0.6));
|
||||
assert!(near(c, Color::gray(0.4)), "{c:?}");
|
||||
let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0));
|
||||
assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}");
|
||||
// Points in different models mix in RGB.
|
||||
assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
|
||||
// Scale 100: a 0.2 spread is a 20 pt disc.
|
||||
|
||||
@@ -241,6 +241,17 @@ impl Color {
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
}
|
||||
/// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in
|
||||
/// display RGB ([`Color::lerp`]).
|
||||
pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*self, *other) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => self.lerp(other, t),
|
||||
}
|
||||
}
|
||||
/// The colour model this colour is authored in.
|
||||
pub fn model(&self) -> cms::Model {
|
||||
match self {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -264,6 +264,8 @@ pub struct RowMap {
|
||||
mean_l: f32,
|
||||
/// Hue turn from the key colour to the new colour (Hue Shift).
|
||||
turn: f32,
|
||||
/// Whether colours take the new colour in its own model, not in the model they had.
|
||||
own_model: bool,
|
||||
}
|
||||
|
||||
impl RowMap {
|
||||
@@ -274,7 +276,20 @@ impl RowMap {
|
||||
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
|
||||
_ => 0.0,
|
||||
};
|
||||
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
|
||||
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false }
|
||||
}
|
||||
|
||||
/// With `own`, current colours take the new colour in the new colour's model (an RGB colour
|
||||
/// replaced by a CMYK one becomes CMYK) instead of keeping the model they had.
|
||||
pub fn with_own_model(mut self, own: bool) -> Self {
|
||||
self.own_model = own;
|
||||
self
|
||||
}
|
||||
|
||||
/// True when current colour `c` keeps a colour model other than the new colour's (so the new
|
||||
/// colour is converted into `c`'s model and may look different from what was asked for).
|
||||
pub fn converts(&self, c: Color) -> bool {
|
||||
!self.own_model && c.model() != self.to.model()
|
||||
}
|
||||
|
||||
/// The colour of the row the new colour stands for.
|
||||
@@ -301,7 +316,7 @@ impl RowMap {
|
||||
|
||||
/// The new colour of current colour `c`, in `c`'s model.
|
||||
pub fn map(&self, c: Color) -> Color {
|
||||
let to = keep_model(c, self.to);
|
||||
let to = if self.own_model { self.to } else { keep_model(c, self.to) };
|
||||
match self.method {
|
||||
Method::Exact => to,
|
||||
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
|
||||
@@ -314,7 +329,13 @@ impl RowMap {
|
||||
Method::HueShift if c == self.key => to,
|
||||
Method::HueShift => {
|
||||
let [h, s, v] = c.to_hsb();
|
||||
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
|
||||
if s <= 0.0 {
|
||||
if self.own_model { c.in_model(self.to.model()) } else { c }
|
||||
} else if self.own_model {
|
||||
Color::from_hsb(h + self.turn, s, v).in_model(self.to.model())
|
||||
} else {
|
||||
keep_model(c, Color::from_hsb(h + self.turn, s, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,6 +597,16 @@ mod tests {
|
||||
assert!(cluster(&[], &[], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() {
|
||||
let to = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)];
|
||||
let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true);
|
||||
assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike");
|
||||
let kept = RowMap::new(Method::HueShift, &row, to);
|
||||
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_snaps_to_the_nearest_colour() {
|
||||
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
|
||||
|
||||
@@ -739,9 +739,10 @@ pub struct Appearance {
|
||||
pub items: Vec<AppearanceItem>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Groups, layers and type: how many items paint below the object's contents (its members, or
|
||||
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
|
||||
/// every item paints above the contents (see [`Self::contents_at`]).
|
||||
/// Groups, layers, type and images: how many items paint below the object's contents (its
|
||||
/// members, type's characters, or an image's pixels), the slot of the Appearance panel's
|
||||
/// Contents (Characters) row. `None`: every item paints above the contents (see
|
||||
/// [`Self::contents_at`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contents_index: Option<usize>,
|
||||
}
|
||||
|
||||
+105
-17
@@ -33,13 +33,7 @@ use crate::text::TextObject;
|
||||
|
||||
/// Interpolate two colours in their shared model (display RGB when the models differ).
|
||||
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*a, *b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => a.lerp(b, t),
|
||||
}
|
||||
a.lerp_keep_model(b, t)
|
||||
}
|
||||
|
||||
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
|
||||
@@ -50,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();
|
||||
@@ -66,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
|
||||
@@ -112,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;
|
||||
@@ -137,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(),
|
||||
@@ -1120,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));
|
||||
|
||||
+328
-25
@@ -1,11 +1,12 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -62,14 +63,42 @@ pub struct HitOptions {
|
||||
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
|
||||
/// bounds.
|
||||
pub type_path_only: bool,
|
||||
/// 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, scope: None }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
|
||||
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
|
||||
/// above this crate, so the engine registers one with [`set_image_paint`].
|
||||
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
|
||||
|
||||
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
|
||||
|
||||
/// Register how the shapes of an image's own fills and strokes are found (the first registration
|
||||
/// stays).
|
||||
pub fn set_image_paint(f: ImagePaint) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = IMAGE_PAINT.set(f);
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
|
||||
/// nothing is registered.
|
||||
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
@@ -110,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
|
||||
}
|
||||
|
||||
@@ -119,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() {
|
||||
@@ -243,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).
|
||||
@@ -259,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);
|
||||
}
|
||||
@@ -318,6 +374,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|
||||
@@ -336,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,
|
||||
@@ -346,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 };
|
||||
@@ -433,12 +522,31 @@ 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)
|
||||
}
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
// 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 {
|
||||
return None;
|
||||
}
|
||||
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
|
||||
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
|
||||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||||
})
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
@@ -495,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.
|
||||
@@ -541,13 +650,16 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
out
|
||||
}
|
||||
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
|
||||
/// ([`shaper_hides`]).
|
||||
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
|
||||
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
|
||||
/// compound paths, layers and groups are touched by their box.
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r)
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
|
||||
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
|
||||
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)),
|
||||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||||
// line) is touched where the marquee crosses it.
|
||||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||||
@@ -558,7 +670,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||||
@@ -574,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 {
|
||||
@@ -822,6 +935,167 @@ mod tests {
|
||||
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
|
||||
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
|
||||
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
|
||||
/// Selection take, or in isolation mode, it selects the member.
|
||||
#[test]
|
||||
fn a_marquee_meets_an_intertwine_group_by_its_members() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
|
||||
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
|
||||
let (a_id, g) = (a.id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
|
||||
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert!(marquee(&d, gap, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
|
||||
// This marquee crosses the first square's left edge.
|
||||
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert_eq!(marquee(&d, edge, None, false), vec![g]);
|
||||
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
|
||||
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
|
||||
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
|
||||
#[test]
|
||||
fn a_marquee_meets_a_repeat_by_its_copies() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||||
// Copies at 0..20, 40..60 and 80..100.
|
||||
let kind = crate::pattern::RepeatKind::Grid {
|
||||
h_spacing: 20.0,
|
||||
v_spacing: 20.0,
|
||||
rows: 1,
|
||||
cols: 3,
|
||||
grid_type: crate::pattern::TileType::Grid,
|
||||
flip_rows: false,
|
||||
flip_cols: false,
|
||||
};
|
||||
let r = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
|
||||
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
|
||||
}
|
||||
|
||||
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
|
||||
/// too: inside the box, away from every key object's and the content's outline, it takes them.
|
||||
#[test]
|
||||
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let l = d.layers[0].id;
|
||||
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
|
||||
let envelope = d.alloc_id();
|
||||
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
|
||||
let e = Node::new(
|
||||
envelope,
|
||||
NodeKind::Envelope {
|
||||
content,
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(l), 1, e).unwrap();
|
||||
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
|
||||
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
|
||||
assert_eq!(marquee(&d, r, None, false), vec![id]);
|
||||
assert_eq!(marquee(&d, r, None, true), vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -889,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]
|
||||
@@ -1116,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+375
-11
@@ -36,6 +36,7 @@ pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod objects_on_path;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
@@ -117,9 +118,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
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};
|
||||
@@ -215,13 +216,33 @@ impl Unit {
|
||||
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
|
||||
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
|
||||
pub fn ruler_step(self, zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 19] =
|
||||
const DECIMAL: [f64; 19] =
|
||||
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
|
||||
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
|
||||
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
|
||||
// between them grow denser, down to a hundred and twenty-eighth
|
||||
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
|
||||
// the left ruler has room for when it spells one down the ruler a character at a time.
|
||||
// From an inch up the ladder is the shared one.
|
||||
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
|
||||
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
|
||||
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
|
||||
/// units, points, pixels and picas are read in tenths.
|
||||
fn ruler_halves(self) -> bool {
|
||||
matches!(self, Unit::Inches)
|
||||
}
|
||||
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
|
||||
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
|
||||
/// as dense as the tenths elsewhere; a tenth of it otherwise.
|
||||
pub fn ruler_minor(self, step: f64) -> f64 {
|
||||
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
|
||||
}
|
||||
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
|
||||
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
|
||||
let tick = self.ruler_step(zoom) / 10.0 * self.points();
|
||||
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
|
||||
(v / tick).round() * tick
|
||||
}
|
||||
pub fn from_pt(self, v: f64) -> f64 {
|
||||
@@ -274,7 +295,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -412,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.
|
||||
@@ -769,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")]
|
||||
@@ -801,6 +935,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
@@ -850,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(),
|
||||
@@ -893,6 +1035,7 @@ impl Document {
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
puppet: None,
|
||||
new_art: Default::default(),
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
@@ -1326,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)))
|
||||
@@ -1339,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] {
|
||||
@@ -1414,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.
|
||||
@@ -1504,6 +1726,7 @@ impl Document {
|
||||
n += usize::from(*k);
|
||||
}
|
||||
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
|
||||
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
|
||||
let before = self.guides.len();
|
||||
let mut flags = kept.into_iter();
|
||||
self.guides.retain(|_| flags.next().unwrap_or(true));
|
||||
@@ -1549,9 +1772,17 @@ impl Document {
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.rename(&renamed);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -1792,6 +2023,46 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
|
||||
/// are binary fractions now, and the minor ticks between them stay binary too; every other
|
||||
/// unit is still read in tenths.
|
||||
#[test]
|
||||
fn an_inch_ruler_steps_in_binary_fractions() {
|
||||
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
|
||||
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
|
||||
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
|
||||
let s = step(Unit::Inches, zoom);
|
||||
if s <= 1.0 {
|
||||
let k = s.recip();
|
||||
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
|
||||
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
|
||||
}
|
||||
// A label step always leaves at least 50 px, which is what the ladder is picked for.
|
||||
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
|
||||
}
|
||||
// The steps the report named: tenths became quarters, eighths and sixteenths.
|
||||
assert_eq!(step(Unit::Inches, 5.0), 0.25);
|
||||
assert_eq!(step(Unit::Inches, 10.0), 0.125);
|
||||
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
|
||||
// An inch and wider is unchanged.
|
||||
assert_eq!(step(Unit::Inches, 1.0), 1.0);
|
||||
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
|
||||
// further only adds ticks. A label therefore never needs more than the six characters of
|
||||
// "0.0625", which the left ruler has room for when it spells one out a character per line.
|
||||
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
|
||||
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
|
||||
}
|
||||
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
|
||||
// 128ths the report expects to appear unlabelled when zoomed in close.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
|
||||
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
|
||||
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
|
||||
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
|
||||
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
|
||||
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
|
||||
}
|
||||
|
||||
fn doc_with_rects() -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
@@ -1851,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);
|
||||
|
||||
+77
-12
@@ -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
|
||||
@@ -730,19 +736,35 @@ impl GradientMesh {
|
||||
.to_point()
|
||||
}
|
||||
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
|
||||
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
|
||||
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
|
||||
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
|
||||
let q =
|
||||
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
|
||||
let cmyk: Option<Vec<[f32; 4]>> =
|
||||
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
|
||||
if let Some(inks) = cmyk {
|
||||
let mut mix = [0.0f32; 4];
|
||||
for (ink, w) in inks.iter().zip(w) {
|
||||
for (m, x) in mix.iter_mut().zip(ink) {
|
||||
*m += x * w as f32;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = mix;
|
||||
return (Color::Cmyk { c, m, y, k }, op);
|
||||
}
|
||||
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
|
||||
if let Some(ks) = grey {
|
||||
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
|
||||
}
|
||||
let mut rgb = [0.0f32; 3];
|
||||
let mut op = 0.0f32;
|
||||
for k in 0..4 {
|
||||
let c = q[k].color.to_rgb();
|
||||
for (ch, x) in rgb.iter_mut().zip(c) {
|
||||
*ch += x * w[k] as f32;
|
||||
for (p, w) in q.iter().zip(w) {
|
||||
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
|
||||
*ch += x * w as f32;
|
||||
}
|
||||
op += q[k].opacity * w[k] as f32;
|
||||
}
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
@@ -1010,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;
|
||||
@@ -1142,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);
|
||||
@@ -1194,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;
|
||||
@@ -1813,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 =
|
||||
@@ -1823,4 +1859,33 @@ mod tests {
|
||||
assert!(m.remove_point_lines(i));
|
||||
assert_eq!((m.rows, m.cols), (1, 1));
|
||||
}
|
||||
|
||||
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
|
||||
#[test]
|
||||
fn a_cmyk_mesh_mixes_its_inks() {
|
||||
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
|
||||
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
|
||||
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
|
||||
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
|
||||
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
|
||||
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
|
||||
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
|
||||
// A corner in another space mixes them all in RGB.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
|
||||
});
|
||||
}
|
||||
b += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
|
||||
@@ -82,6 +82,31 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
pinned_or(|n| std::env::var(n).ok(), now_unix)
|
||||
}
|
||||
|
||||
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
|
||||
/// variable is preferred and that the clock answers whenever it does not.
|
||||
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
|
||||
stamp_from_env(get).or_else(clock)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +173,38 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
|
||||
/// What a file records: the variable when it is usable, the clock whenever it is not. The
|
||||
/// environment and the clock are passed in, so no test changes this process's environment --
|
||||
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
|
||||
#[test]
|
||||
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
|
||||
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
|
||||
let clock = || Some(1_000_000);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
|
||||
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
|
||||
for ignored in [None, Some(""), Some("-1"), Some("later")] {
|
||||
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
|
||||
}
|
||||
// A platform with no clock and no variable has no date to record.
|
||||
assert_eq!(pinned_or(env(None), || None), None);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
|
||||
}
|
||||
}
|
||||
+151
-36
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
@@ -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.
|
||||
@@ -102,6 +105,8 @@ pub enum LiveShape {
|
||||
},
|
||||
Polygon {
|
||||
radius: f64,
|
||||
/// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read.
|
||||
#[serde(deserialize_with = "de_sides")]
|
||||
sides: u32,
|
||||
xf: Affine,
|
||||
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
|
||||
@@ -110,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,
|
||||
@@ -127,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",
|
||||
@@ -144,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).
|
||||
@@ -251,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;
|
||||
@@ -272,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;
|
||||
@@ -282,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]);
|
||||
@@ -298,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);
|
||||
@@ -326,6 +360,12 @@ fn all_round(kinds: &[CornerKind]) -> bool {
|
||||
kinds.iter().all(|k| *k == CornerKind::Round)
|
||||
}
|
||||
|
||||
/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its
|
||||
/// corner lists hold one entry per side, so a huge count would allocate gigabytes.
|
||||
fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
|
||||
u32::deserialize(d).map(|n| n.clamp(3, 1000))
|
||||
}
|
||||
|
||||
/// Whether `a` only moves (no rotation, scale, shear or reflection).
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
@@ -335,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();
|
||||
@@ -572,6 +612,9 @@ pub struct NodeExtras {
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -590,6 +633,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -724,6 +768,7 @@ impl Serialize for Node {
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -762,6 +807,7 @@ impl Serialize for Node {
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -821,6 +867,8 @@ struct NodeWire {
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -840,6 +888,7 @@ impl From<NodeWire> for Node {
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1025,6 +1074,7 @@ impl Node {
|
||||
match &self.kind {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -1087,8 +1137,10 @@ impl Node {
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
|
||||
/// measured as in [`Node::visual_bounds`].
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -1106,6 +1158,22 @@ impl Node {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// The members' reach, grown by the container's own strokes.
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
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.reach(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
|
||||
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
|
||||
// the farthest it can reach from its shape.
|
||||
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -1123,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> {
|
||||
@@ -1132,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()),
|
||||
@@ -1155,9 +1234,9 @@ impl Node {
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
|
||||
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
|
||||
/// ([`Node::visual_bounds_with`] measures its art).
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
|
||||
/// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`],
|
||||
/// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
self.visual(None)
|
||||
}
|
||||
@@ -1187,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)));
|
||||
@@ -1232,6 +1307,8 @@ impl Node {
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
NodeKind::Image(_) => self.geometric(symbols),
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1251,6 +1328,12 @@ impl Node {
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
// The frames of the objects attached to a path move with them.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -1683,11 +1766,11 @@ impl Node {
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
|
||||
/// whose fills and strokes paint their own geometry.
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
/// for type; `None` for objects whose fills and strokes paint their own geometry.
|
||||
pub fn contents_label(&self) -> Option<&'static str> {
|
||||
match self.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
|
||||
NodeKind::Text(_) => Some("Characters"),
|
||||
_ => None,
|
||||
}
|
||||
@@ -2124,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}");
|
||||
@@ -2145,21 +2242,39 @@ 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);
|
||||
assert_eq!(path.label(), "Path");
|
||||
}
|
||||
|
||||
/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one
|
||||
/// with four billion made its outline and corner lists allocate tens of gigabytes.
|
||||
#[test]
|
||||
fn a_polygon_from_a_file_has_3_to_1000_sides() {
|
||||
let read = |sides: u32| {
|
||||
let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]});
|
||||
serde_json::from_value::<LiveShape>(json).unwrap()
|
||||
};
|
||||
let sides_of = |l: &LiveShape| match l {
|
||||
LiveShape::Polygon { sides, .. } => *sides,
|
||||
_ => panic!("not a polygon: {l:?}"),
|
||||
};
|
||||
let huge = read(4_000_000_000);
|
||||
assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]);
|
||||
assert_eq!(sides_of(&huge), 1000);
|
||||
assert_eq!(huge.polygon_outline().anchor_count(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
|
||||
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));
|
||||
@@ -2184,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));
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Object › Objects on Path: objects attached to a path and spread along it.
|
||||
//!
|
||||
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
|
||||
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
|
||||
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
|
||||
//! saves and exports as a group. The record lists the objects in their order along the path, each
|
||||
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
|
||||
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
|
||||
//! one sits on the end of an open path), puts the attachment point of each one
|
||||
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
|
||||
//! tilted by [`ObjectsOnPath::rotate`].
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::blend::Spine;
|
||||
use crate::symbols::SymbolBoxes;
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// The center of the nine reference points.
|
||||
pub const CENTER: u8 = 4;
|
||||
|
||||
fn center() -> u8 {
|
||||
CENTER
|
||||
}
|
||||
|
||||
/// What makes a group an Objects on Path group (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectsOnPath {
|
||||
/// The child of the group that is the path.
|
||||
pub path: NodeId,
|
||||
/// The attached objects, in their order along the path.
|
||||
#[serde(default)]
|
||||
pub members: Vec<Member>,
|
||||
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
|
||||
/// from the top left of the object's bounds in its frame (4: the center).
|
||||
#[serde(default = "center")]
|
||||
pub pivot: u8,
|
||||
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
|
||||
/// upright object has its frame's x axis along the path's direction at its attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub rotate: f64,
|
||||
}
|
||||
|
||||
/// An object attached to the path of an Objects on Path group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "MemberWire")]
|
||||
pub struct Member {
|
||||
pub id: NodeId,
|
||||
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
|
||||
pub frame: Affine,
|
||||
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
|
||||
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
|
||||
/// the frame's x axis before then.
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
|
||||
#[derive(Deserialize)]
|
||||
struct MemberWire {
|
||||
id: NodeId,
|
||||
frame: Affine,
|
||||
#[serde(default)]
|
||||
angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<MemberWire> for Member {
|
||||
fn from(w: MemberWire) -> Self {
|
||||
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
|
||||
Self { id: w.id, frame: w.frame, angle }
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `frame`'s x axis (radians, the document's axes).
|
||||
fn x_angle(frame: Affine) -> f64 {
|
||||
let [a0, a1, ..] = frame.as_coeffs();
|
||||
a1.atan2(a0)
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// `n` in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(n: &Node) -> Self {
|
||||
let frame = upright_frame(n);
|
||||
Self { id: n.id, frame, angle: x_angle(frame) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectsOnPath {
|
||||
/// A record for the objects `members` (in their order along the path) attached to `path`, each
|
||||
/// in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
|
||||
let members = members.iter().map(|n| Member::new(n)).collect();
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
|
||||
}
|
||||
/// The frames go with the group, and each frame's angle becomes the transformed path's
|
||||
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
|
||||
/// and reflection when it spreads the objects along the transformed path again. A reflection
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
let before = tilt(self.rotate);
|
||||
if a.determinant() < 0.0 && self.rotate != 0.0 {
|
||||
self.rotate = -self.rotate;
|
||||
}
|
||||
let after = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
for m in &mut self.members {
|
||||
m.frame = a * m.frame;
|
||||
let (sin, cos) = (m.angle - before).sin_cos();
|
||||
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
|
||||
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
|
||||
m.angle = y.atan2(x) + after;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
self.path = to(self.path);
|
||||
for m in &mut self.members {
|
||||
m.id = to(m.id);
|
||||
}
|
||||
}
|
||||
/// The group's children changed from `before` to `after`: a new child at the index of a member
|
||||
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
|
||||
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
|
||||
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
|
||||
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
|
||||
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
|
||||
let mut ids = HashMap::new();
|
||||
for (old, new) in before.iter().zip(after) {
|
||||
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
|
||||
ids.insert(old.id, new.id);
|
||||
}
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
self.rename(&ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an Objects on Path group?
|
||||
pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
|
||||
}
|
||||
|
||||
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
|
||||
/// (not a guide) whose first subpath has some length.
|
||||
pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn upright_frame(n: &Node) -> Affine {
|
||||
let a = n.bbox_angle;
|
||||
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
|
||||
match n.geometric_bounds() {
|
||||
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
|
||||
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
|
||||
/// around. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
|
||||
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
|
||||
}
|
||||
|
||||
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
|
||||
/// counter-clockwise on the page.
|
||||
fn tilt(rotate: f64) -> f64 {
|
||||
-rotate.to_radians()
|
||||
}
|
||||
|
||||
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
|
||||
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
|
||||
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
|
||||
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
|
||||
let det = m.frame.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut local = n.clone();
|
||||
local.transform(m.frame.inverse(), false);
|
||||
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
|
||||
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
|
||||
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
|
||||
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `a` the identity, give or take rounding?
|
||||
fn negligible(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
|
||||
/// record lets go of the objects that are no longer in the group, and children it does not list
|
||||
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
|
||||
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
|
||||
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
|
||||
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
|
||||
let old = rec.clone();
|
||||
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
|
||||
let path_id = rec.path;
|
||||
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
|
||||
g.extras_mut().objects_on_path = None;
|
||||
return true;
|
||||
};
|
||||
let path = attachable_path(path).cloned();
|
||||
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
|
||||
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
|
||||
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
|
||||
for c in children.iter() {
|
||||
if c.id != path_id && listed.insert(c.id) {
|
||||
rec.members.push(Member::new(c));
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
|
||||
let turn = tilt(rec.rotate);
|
||||
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
|
||||
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
|
||||
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
|
||||
let delta = next * m.frame.inverse();
|
||||
if !negligible(delta) {
|
||||
Arc::make_mut(c).transform(delta, false);
|
||||
*m = Member { id: m.id, frame: next, angle: dir + turn };
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec != old {
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
return true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn rect(id: u64, r: Rect) -> Node {
|
||||
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_spread_from_the_first_anchor() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let s = stops(&open, 4).unwrap();
|
||||
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
|
||||
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let s = stops(&closed, 4).unwrap();
|
||||
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
pts.sort();
|
||||
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_frames_round_the_rotation_to_a_right_angle() {
|
||||
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 90.0;
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 30.0;
|
||||
let f = upright_frame(&n);
|
||||
let [a0, a1, ..] = f.as_coeffs();
|
||||
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_placed_by_its_art() {
|
||||
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
|
||||
// around it that instances are measured by without the symbols.
|
||||
let mut d = crate::Document::new(400.0, 400.0);
|
||||
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
|
||||
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
|
||||
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
|
||||
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let members = [&instance, &other];
|
||||
let mut g = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
|
||||
);
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(place(&mut g, &symbols));
|
||||
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
|
||||
let b = placed.geometric_bounds_with(&symbols).unwrap();
|
||||
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
|
||||
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
|
||||
let mut v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
|
||||
v.as_object_mut().unwrap().remove("angle");
|
||||
let back: Member = serde_json::from_value(v).unwrap();
|
||||
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_without_its_path_is_a_plain_group() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
|
||||
let d = crate::Document::new(100.0, 100.0);
|
||||
assert!(place(&mut g, &d.symbol_boxes()));
|
||||
assert!(!is_objects_on_path(&g));
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
+65
-13
@@ -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};
|
||||
|
||||
@@ -43,6 +44,16 @@ pub struct Selection {
|
||||
/// guides and objects can be selected together, in either order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
/// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index
|
||||
/// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set
|
||||
/// 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 {
|
||||
@@ -67,6 +78,11 @@ impl Selection {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.key_guide = None;
|
||||
}
|
||||
/// Is there a key for Align: an object or a guide?
|
||||
pub fn has_key(&self) -> bool {
|
||||
self.key.is_some() || self.key_guide.is_some()
|
||||
}
|
||||
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
@@ -114,6 +130,9 @@ impl Selection {
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
if self.key_guide == Some(i) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
} else {
|
||||
self.guides.push(i);
|
||||
}
|
||||
@@ -135,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)) {
|
||||
@@ -162,13 +185,16 @@ impl Selection {
|
||||
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
|
||||
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
|
||||
});
|
||||
// The key object stays while another selected object lies outside it.
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
// The key object stays while another selected object lies outside it, or while ruler
|
||||
// guides are selected for it to line up (#2143).
|
||||
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
|
||||
let mut inside = BTreeSet::new();
|
||||
n.walk(&mut |c| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
self.objects.iter().any(|o| !inside.contains(o))
|
||||
self.objects.iter().any(|o| !inside.contains(o)) || (self.objects.contains(&n.id) && !self.guides.is_empty())
|
||||
});
|
||||
if !keep {
|
||||
self.key = None;
|
||||
@@ -177,23 +203,45 @@ impl Selection {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
// A key guide needs objects to line up on it, and stays selected.
|
||||
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -207,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);
|
||||
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
+398
-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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +523,127 @@ impl Default for HyphenationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SingleWordJustify {
|
||||
/// Spread across the line, between its letters.
|
||||
#[default]
|
||||
FullJustify,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
}
|
||||
|
||||
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
|
||||
///
|
||||
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
|
||||
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
|
||||
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
|
||||
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
|
||||
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
|
||||
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct JustificationOptions {
|
||||
/// Word Spacing, percent of the font's word space (0..1000).
|
||||
pub word_spacing: [f64; 3],
|
||||
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
|
||||
pub letter_spacing: [f64; 3],
|
||||
/// Glyph Scaling, percent of the glyphs' width (50..200).
|
||||
pub glyph_scaling: [f64; 3],
|
||||
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
|
||||
/// (0..500).
|
||||
pub auto_leading: f64,
|
||||
/// Single Word Justification: how a justified line holding one word is set.
|
||||
pub single_word: SingleWordJustify,
|
||||
}
|
||||
|
||||
impl JustificationOptions {
|
||||
/// Word Spacing's range (percent).
|
||||
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
|
||||
/// Letter Spacing's range (percent).
|
||||
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
|
||||
/// Glyph Scaling's range (percent).
|
||||
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
|
||||
/// Auto Leading's range (percent).
|
||||
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
|
||||
|
||||
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
|
||||
/// in a line's natural widths)?
|
||||
pub fn desired_is_default(&self) -> bool {
|
||||
let d = Self::default();
|
||||
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
|
||||
}
|
||||
|
||||
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
|
||||
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
|
||||
/// Desired and Maximum out of order go back to their defaults together.
|
||||
pub fn keep_in_range(&mut self) {
|
||||
let d = Self::default();
|
||||
for (v, dv, (lo, hi)) in [
|
||||
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
|
||||
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
|
||||
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
|
||||
] {
|
||||
for (x, dx) in v.iter_mut().zip(dv) {
|
||||
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
|
||||
}
|
||||
if !(v[0] <= v[1] && v[1] <= v[2]) {
|
||||
*v = dv;
|
||||
}
|
||||
}
|
||||
let (lo, hi) = Self::AUTO_LEADING;
|
||||
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
|
||||
}
|
||||
|
||||
/// A paragraph's leading for characters of `size` whose leading is Auto.
|
||||
pub fn auto_leading_for(&self, size: f64) -> f64 {
|
||||
size * self.auto_leading / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JustificationOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
word_spacing: [80.0, 100.0, 133.0],
|
||||
letter_spacing: [0.0, 0.0, 0.0],
|
||||
glyph_scaling: [100.0, 100.0, 100.0],
|
||||
auto_leading: 120.0,
|
||||
single_word: SingleWordJustify::FullJustify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -522,6 +660,9 @@ pub struct ParaStyle {
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
|
||||
/// (Paragraph panel menu › Justification…).
|
||||
pub justification: JustificationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -550,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 {
|
||||
@@ -591,6 +735,8 @@ struct ParaStyleFile {
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justification: JustificationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -609,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 {
|
||||
@@ -627,6 +775,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -636,6 +785,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -648,6 +798,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -657,6 +808,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -664,6 +816,8 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let mut justification = f.justification;
|
||||
justification.keep_in_range();
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
@@ -681,6 +835,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -692,6 +847,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -701,6 +857,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1085,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 }
|
||||
@@ -1150,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
|
||||
@@ -1165,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`.
|
||||
@@ -1189,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>,
|
||||
@@ -1245,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(),
|
||||
@@ -1259,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(),
|
||||
}
|
||||
@@ -1266,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>()
|
||||
@@ -1556,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]
|
||||
@@ -1585,6 +1950,37 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
|
||||
}
|
||||
|
||||
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
|
||||
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
|
||||
/// Maximum out of order go back to their defaults.
|
||||
#[test]
|
||||
fn justification_options_read_from_a_file_stay_in_range() {
|
||||
let d = JustificationOptions::default();
|
||||
let read: ParaStyle = serde_json::from_value(serde_json::json!({
|
||||
"justification": {
|
||||
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
|
||||
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let j = read.justification;
|
||||
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
|
||||
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
|
||||
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
|
||||
assert_eq!(j.auto_leading, 0.0);
|
||||
let mut j = JustificationOptions {
|
||||
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
|
||||
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
|
||||
auto_leading: f64::NAN,
|
||||
..d.clone()
|
||||
};
|
||||
j.keep_in_range();
|
||||
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
|
||||
let mut same = d.clone();
|
||||
same.keep_in_range();
|
||||
assert_eq!(same, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -48,15 +48,29 @@ enum Channel {
|
||||
|
||||
impl Channel {
|
||||
fn of(p: &Value) -> Self {
|
||||
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => Channel::One(0),
|
||||
"green" | "g" => Channel::One(1),
|
||||
"blue" | "b" => Channel::One(2),
|
||||
_ => Channel::All,
|
||||
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"rgb" => Some(Channel::All),
|
||||
"red" | "r" => Some(Channel::One(0)),
|
||||
"green" | "g" => Some(Channel::One(1)),
|
||||
"blue" | "b" => Some(Channel::One(2)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
|
||||
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
|
||||
|
||||
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
|
||||
/// case? They draw any other value on all three channels, as RGB.
|
||||
pub fn is_channel(s: &str) -> bool {
|
||||
Channel::parse(s).is_some()
|
||||
}
|
||||
|
||||
/// Entries of a sampled tone curve.
|
||||
const LUT: usize = 256;
|
||||
|
||||
|
||||
@@ -107,9 +107,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
|
||||
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
|
||||
// type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
@@ -150,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();
|
||||
|
||||
+221
-12
@@ -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;
|
||||
@@ -56,25 +60,29 @@ mod tests;
|
||||
mod tests_scale;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use adjust::{
|
||||
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
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};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
|
||||
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};
|
||||
|
||||
@@ -140,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"]),
|
||||
@@ -150,7 +159,11 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"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(),
|
||||
}
|
||||
@@ -338,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…",
|
||||
@@ -345,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…",
|
||||
@@ -352,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…",
|
||||
@@ -359,6 +393,27 @@ 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…",
|
||||
ARTISTIC,
|
||||
"{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.posterEdges",
|
||||
"Poster Edges…",
|
||||
@@ -366,6 +421,57 @@ 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…",
|
||||
ARTISTIC,
|
||||
"{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.sponge",
|
||||
"Sponge…",
|
||||
ARTISTIC,
|
||||
"{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…",
|
||||
ARTISTIC,
|
||||
"{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}),
|
||||
),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
@@ -464,13 +570,97 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.basRelief",
|
||||
"Bas Relief…",
|
||||
SKETCH,
|
||||
"{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}),
|
||||
),
|
||||
r(
|
||||
"sketch.chalkCharcoal",
|
||||
"Chalk & Charcoal…",
|
||||
SKETCH,
|
||||
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
|
||||
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.charcoal",
|
||||
"Charcoal…",
|
||||
SKETCH,
|
||||
"{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.chrome",
|
||||
"Chrome…",
|
||||
SKETCH,
|
||||
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 4.0, "smoothness": 4.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.graphicPen",
|
||||
"Graphic Pen…",
|
||||
SKETCH,
|
||||
"{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.",
|
||||
json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"sketch.notePaper",
|
||||
"Note Paper…",
|
||||
SKETCH,
|
||||
"{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
|
||||
json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.photocopy",
|
||||
"Photocopy…",
|
||||
SKETCH,
|
||||
"{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 7.0, "darkness": 25.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.plaster",
|
||||
"Plaster…",
|
||||
SKETCH,
|
||||
"{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}),
|
||||
),
|
||||
r(
|
||||
"sketch.reticulation",
|
||||
"Reticulation…",
|
||||
SKETCH,
|
||||
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.stamp",
|
||||
"Stamp…",
|
||||
SKETCH,
|
||||
"{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…",
|
||||
@@ -545,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(
|
||||
@@ -628,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,
|
||||
@@ -838,6 +1038,12 @@ pub struct GeomContext<'a> {
|
||||
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
|
||||
/// way and keeps its result on every redraw.
|
||||
pub seed: u64,
|
||||
/// The object's appearance, which live-effect plug-ins are given read only (their fills and
|
||||
/// strokes).
|
||||
pub appearance: Option<&'a Appearance>,
|
||||
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
|
||||
/// the object's effects.
|
||||
pub item: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> GeomContext<'a> {
|
||||
@@ -847,15 +1053,17 @@ impl<'a> GeomContext<'a> {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
|
||||
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
/// stroke's own effects outline that stroke.
|
||||
/// stroke's own effects outline that stroke, and a plug-in is told which item it is on.
|
||||
pub fn item(self, item: &'a AppearanceItem) -> Self {
|
||||
let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item)));
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
|
||||
AppearanceItem::Fill(_) => self,
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self },
|
||||
AppearanceItem::Fill(_) => Self { item: at, ..self },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,7 +1102,8 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
|
||||
@@ -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,16 +1,78 @@
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, 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 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::{Place, cell, rand};
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
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;
|
||||
|
||||
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.
|
||||
@@ -159,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
|
||||
@@ -239,11 +350,347 @@ 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))
|
||||
}
|
||||
|
||||
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
|
||||
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
|
||||
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
|
||||
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
|
||||
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
|
||||
if strength == 0.0 {
|
||||
return;
|
||||
}
|
||||
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
|
||||
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = px.len();
|
||||
let mut height = vec![0.0f32; len];
|
||||
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
|
||||
let mut noise = CellNoise::default();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
let l = luma(straight(*p));
|
||||
let q = place.at(x, y);
|
||||
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
|
||||
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
|
||||
});
|
||||
for (h, a) in height.iter_mut().zip(&alpha) {
|
||||
*h *= *a;
|
||||
}
|
||||
blur_plane(&mut height, w, h, sigma);
|
||||
let mut cov = alpha.clone();
|
||||
blur_plane(&mut cov, w, h, sigma);
|
||||
for (h, c) in height.iter_mut().zip(&cov) {
|
||||
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
|
||||
}
|
||||
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
|
||||
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
|
||||
let light = LIGHT.map(|v| v / inv);
|
||||
let scale = (8.0 * space.px).max(1e-6) as f32;
|
||||
let sample = |x: isize, y: isize| -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
height[y as usize * w + x as usize]
|
||||
};
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
|
||||
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
|
||||
let nx = -gx / scale;
|
||||
let ny = -gy / scale;
|
||||
let nz = 1.0;
|
||||
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
|
||||
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
|
||||
let spec = ndotl.powi(12) * strength;
|
||||
if spec <= 1e-5 {
|
||||
return;
|
||||
}
|
||||
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -268,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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,9 @@ impl StrokeDirection {
|
||||
}
|
||||
|
||||
/// Along a right diagonal stroke, rising to the right (y down).
|
||||
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
pub(super) const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
/// Along a left diagonal stroke, falling to the right.
|
||||
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
|
||||
pub(super) const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
|
||||
|
||||
/// The most samples a stroke averages, whatever its length.
|
||||
const MAX_SAMPLES: usize = 16;
|
||||
@@ -75,7 +75,7 @@ const MAX_SAMPLES: usize = 16;
|
||||
const JITTER: f64 = 0.25;
|
||||
|
||||
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
|
||||
const TONE_SOFTNESS: f64 = 1.0;
|
||||
pub(super) const TONE_SOFTNESS: f64 = 1.0;
|
||||
|
||||
/// Dark Strokes' short dark strokes and long light ones (points).
|
||||
pub(super) const DARK_LENGTH: f64 = 3.0;
|
||||
@@ -86,7 +86,7 @@ const INK_SOFTNESS: f64 = 0.6;
|
||||
|
||||
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
|
||||
/// between pixels add to a filter's reach.
|
||||
const MARGIN: f64 = 1.0;
|
||||
pub(super) const MARGIN: f64 = 1.0;
|
||||
|
||||
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
|
||||
/// softening between their samples and the pixels a sample is taken between.
|
||||
@@ -137,18 +137,18 @@ pub(super) fn spray_shift(radius: f64) -> f64 {
|
||||
|
||||
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
|
||||
/// softened first when they lie more than a pixel apart.
|
||||
struct Smear<'a> {
|
||||
pub(super) struct Smear<'a> {
|
||||
/// Premultiplied colours (0..1).
|
||||
src: Vec<[f32; 4]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
place: &'a Place,
|
||||
len: f64,
|
||||
pub(super) len: f64,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl<'a> Smear<'a> {
|
||||
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
|
||||
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
|
||||
let span = len / place.px;
|
||||
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
|
||||
@@ -218,7 +218,7 @@ impl<'a> Smear<'a> {
|
||||
}
|
||||
|
||||
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
|
||||
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
|
||||
pub(super) fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
|
||||
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -242,37 +242,37 @@ fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
|
||||
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
|
||||
pub(super) fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
|
||||
let (u, v) = frame(q, dir);
|
||||
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
|
||||
}
|
||||
|
||||
/// The straight colour and coverage of premultiplied `pm` (0..1).
|
||||
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
|
||||
pub(super) fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
|
||||
}
|
||||
|
||||
/// `a` + (`b` − `a`) × `t`, channel by channel.
|
||||
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
|
||||
pub(super) fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
|
||||
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
|
||||
}
|
||||
|
||||
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
|
||||
fn ramp(t: f64) -> f64 {
|
||||
pub(super) fn ramp(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
|
||||
fn unit(p: [u8; 4]) -> [f32; 4] {
|
||||
pub(super) fn unit(p: [u8; 4]) -> [f32; 4] {
|
||||
p.map(|v| f32::from(v) / 255.0)
|
||||
}
|
||||
|
||||
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
|
||||
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
|
||||
/// match.
|
||||
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
pub(super) fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
@@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
const SALT_GRAPHIC: u64 = 200;
|
||||
|
||||
/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15)
|
||||
/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more
|
||||
/// black ink).
|
||||
pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0;
|
||||
let row = (0.2 * len).max(0.75);
|
||||
let along = dir.along();
|
||||
place.each(px, |x, y, p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row));
|
||||
let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a));
|
||||
if dark <= threshold {
|
||||
*p = premultiply([1.0, 1.0, 1.0], a);
|
||||
return;
|
||||
}
|
||||
let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3));
|
||||
let u = frame(q, along).0;
|
||||
let on_stroke = {
|
||||
let t = (u / len).rem_euclid(1.0);
|
||||
if t < 0.65 { 1.0 } else { 0.0 }
|
||||
};
|
||||
let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5);
|
||||
let ink = (weight * on_stroke + grain).clamp(0.0, 1.0);
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], a);
|
||||
});
|
||||
}
|
||||
+352
-14
@@ -1,7 +1,9 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, 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 › Halftone Pattern, Stylize ›
|
||||
//! 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 › 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.
|
||||
@@ -19,9 +21,11 @@ mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod loaded;
|
||||
mod paint;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod sketch_ink;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -30,20 +34,32 @@ 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; 32] = [
|
||||
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",
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -62,7 +78,19 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.basRelief",
|
||||
"sketch.chalkCharcoal",
|
||||
"sketch.charcoal",
|
||||
"sketch.chrome",
|
||||
"sketch.graphicPen",
|
||||
"sketch.halftonePattern",
|
||||
"sketch.notePaper",
|
||||
"sketch.photocopy",
|
||||
"sketch.plaster",
|
||||
"sketch.reticulation",
|
||||
"sketch.stamp",
|
||||
"sketch.tornEdges",
|
||||
"sketch.waterPaper",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -78,19 +106,57 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
/// 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).
|
||||
SmudgeStick { stroke: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Sponge: soft blobs of lighter and darker colour about `brush` (0..10) large,
|
||||
/// contrasting as `definition` (0..25) says, their edges as soft as `smoothness` (1..15).
|
||||
Sponge { brush: f64, definition: f64, smoothness: f64 },
|
||||
/// Artistic › Watercolor: detail simplified by a brush of `detail` (1..14), edges saturated,
|
||||
/// shadows deepened by `shadow` (0..10), `texture` (1..3) grains of paper showing.
|
||||
Watercolor { detail: f64, shadow: f64, texture: u32 },
|
||||
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
|
||||
/// 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 },
|
||||
@@ -139,9 +205,46 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
|
||||
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
|
||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
|
||||
Chrome { detail: f64, smoothness: f64 },
|
||||
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
||||
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
||||
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Sketch › Note Paper: embossed tones on fibrous paper (`balance`, `graininess`, `relief`).
|
||||
NotePaper { balance: f64, graininess: f64, relief: f64 },
|
||||
/// Sketch › Bas Relief: low relief lit from `light`, keeping the finer features as `detail`
|
||||
/// (1..15) says and rounded by `smoothness` (1..15).
|
||||
BasRelief { detail: f64, smoothness: f64, light: Light },
|
||||
/// Sketch › Charcoal: bold edges `thickness` (1..7) wide, as fine as `detail` (0..5) says, and
|
||||
/// diagonal strokes over the midtones; `balance` (0..100) sets how far into the lights it reaches.
|
||||
Charcoal { thickness: f64, detail: f64, balance: f64 },
|
||||
/// Sketch › Photocopy: black only where a pixel is darker than its surroundings, as fine as
|
||||
/// `detail` (1..24) says, and as readily as `darkness` (1..50) says.
|
||||
Photocopy { detail: f64, darkness: f64 },
|
||||
/// Sketch › Stamp: solid black and white, printing black as much as `balance` (0..50) says,
|
||||
/// its shapes rounded by `smoothness` (1..50).
|
||||
Stamp { balance: f64, smoothness: f64 },
|
||||
/// Sketch › Plaster: relief plaster lit from `light`; dark areas raised, light recessed;
|
||||
/// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15)
|
||||
/// softens the relief.
|
||||
Plaster { image_balance: f64, smoothness: f64, light: Light },
|
||||
/// 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
|
||||
@@ -222,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),
|
||||
@@ -242,6 +360,54 @@ 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),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.sponge" => PixelFx::Sponge {
|
||||
brush: num(p, "brushSize", 2.0).clamp(0.0, 10.0),
|
||||
definition: num(p, "definition", 12.0).clamp(0.0, 25.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"artistic.watercolor" => PixelFx::Watercolor {
|
||||
detail: num(p, "brushDetail", 9.0).clamp(1.0, 14.0),
|
||||
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),
|
||||
smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
@@ -311,11 +477,63 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.chalkCharcoal" => PixelFx::ChalkCharcoal {
|
||||
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
|
||||
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||
},
|
||||
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
|
||||
"sketch.graphicPen" => PixelFx::GraphicPen {
|
||||
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"sketch.notePaper" => PixelFx::NotePaper {
|
||||
balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
|
||||
graininess: num(p, "graininess", 0.0).clamp(0.0, 10.0),
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"sketch.basRelief" => PixelFx::BasRelief {
|
||||
detail: num(p, "detail", 13.0).clamp(1.0, 15.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
light: Light::parse(text(p, "lightDirection", "bottom")),
|
||||
},
|
||||
"sketch.charcoal" => PixelFx::Charcoal {
|
||||
thickness: num(p, "thickness", 1.0).clamp(1.0, 7.0),
|
||||
detail: num(p, "detail", 1.0).clamp(0.0, 5.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
},
|
||||
"sketch.photocopy" => {
|
||||
PixelFx::Photocopy { detail: num(p, "detail", 7.0).clamp(1.0, 24.0), darkness: num(p, "darkness", 25.0).clamp(1.0, 50.0) }
|
||||
}
|
||||
"sketch.stamp" => {
|
||||
PixelFx::Stamp { balance: num(p, "lightDarkBalance", 25.0).clamp(0.0, 50.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 50.0) }
|
||||
}
|
||||
"sketch.plaster" => PixelFx::Plaster {
|
||||
image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0),
|
||||
smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
},
|
||||
"sketch.reticulation" => PixelFx::Reticulation {
|
||||
density: num(p, "density", 12.0).clamp(0.0, 50.0),
|
||||
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),
|
||||
@@ -353,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")),
|
||||
@@ -381,7 +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::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),
|
||||
@@ -392,12 +614,29 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. }
|
||||
| PixelFx::ChalkCharcoal { .. }
|
||||
| PixelFx::Chrome { .. }
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Plaster { .. }
|
||||
| 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 { .. }
|
||||
@@ -420,9 +659,16 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
|
||||
PixelFx::SmudgeStick { stroke, .. } => Some(paint::smudge_reach(stroke)),
|
||||
PixelFx::Sponge { brush, smoothness, .. } => Some(paint::sponge_reach(brush, smoothness)),
|
||||
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::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
|
||||
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))
|
||||
}
|
||||
@@ -435,17 +681,37 @@ impl PixelFx {
|
||||
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
|
||||
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
PixelFx::BasRelief { detail, smoothness, .. } => Some(sketch_ink::relief_reach(detail, smoothness)),
|
||||
PixelFx::Charcoal { thickness, detail, .. } => Some(sketch_ink::charcoal_reach(thickness, detail)),
|
||||
PixelFx::Photocopy { detail, .. } => Some(sketch_ink::copy_reach(detail)),
|
||||
PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
|
||||
// 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::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
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 { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| 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)),
|
||||
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.
|
||||
@@ -466,12 +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)
|
||||
@@ -493,7 +776,19 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
|
||||
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
|
||||
PixelFx::BasRelief { detail, smoothness, light } => sketch_ink::bas_relief(px, w, h, space, detail, smoothness, light),
|
||||
PixelFx::Charcoal { thickness, detail, balance } => sketch_ink::charcoal(px, w, h, space, thickness, detail, balance),
|
||||
PixelFx::Photocopy { detail, darkness } => sketch_ink::photocopy(px, w, h, space, detail, darkness),
|
||||
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),
|
||||
@@ -504,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),
|
||||
@@ -594,6 +889,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
@@ -703,3 +1037,7 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
#[cfg(test)]
|
||||
mod tests_sketch;
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Effect › Artistic › Smudge Stick, Sponge 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> (Smudge Stick smudges the
|
||||
//! dark areas with short diagonal strokes and brightens the light ones, Sponge paints highly
|
||||
//! textured areas of contrasting colour, Watercolor simplifies detail with a medium brush loaded
|
||||
//! with water and saturates the colour where tones change at edges). Visual approximations, not
|
||||
//! reproductions of a proprietary algorithm; the ranges and defaults are chosen for VectorCraft.
|
||||
//!
|
||||
//! All three keep the object's coverage, so they paint nothing outside it. Their patterns lie in
|
||||
//! document space around the object's centre, as the Brush Strokes' and Texture filters' do.
|
||||
|
||||
use super::blur;
|
||||
use super::brushstrokes::{MARGIN, RIGHT, Smear, TONE_SOFTNESS, edges, mix, ramp, streak, stroke_reach, unpremultiply};
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, Px16, gaussian, to16};
|
||||
|
||||
const SALT_SMUDGE: u64 = 320;
|
||||
const SALT_SPONGE: u64 = 328;
|
||||
const SALT_WATER: u64 = 336;
|
||||
|
||||
/// The length (points) of Smudge Stick's short strokes for Stroke Length `stroke` (0..10); the
|
||||
/// light areas are smudged with strokes twice as long.
|
||||
fn smudge_length(stroke: f64) -> f64 {
|
||||
1.0 + 1.5 * stroke.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Smudge Stick reaches for Stroke Length `stroke`: its strokes and the
|
||||
/// softened tones that choose them.
|
||||
pub(super) fn smudge_reach(stroke: f64) -> f64 {
|
||||
stroke_reach(2.0 * smudge_length(stroke)).max(3.0 * TONE_SOFTNESS + MARGIN)
|
||||
}
|
||||
|
||||
/// The σ (points) Sponge softens the object by, for Brush Size `brush` (0..10) and Smoothness
|
||||
/// `smoothness` (1..15).
|
||||
fn sponge_softness(brush: f64, smoothness: f64) -> f64 {
|
||||
(0.15 + 0.04 * smoothness.clamp(1.0, 15.0)) * sponge_pore(brush)
|
||||
}
|
||||
|
||||
/// The size (points) of Sponge's pores for Brush Size `brush` (0..10).
|
||||
fn sponge_pore(brush: f64) -> f64 {
|
||||
2.0 + 1.5 * brush.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Sponge reaches: the softened colours around a pixel.
|
||||
pub(super) fn sponge_reach(brush: f64, smoothness: f64) -> f64 {
|
||||
3.0 * sponge_softness(brush, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// The radius (points) of the brush that simplifies detail for Brush Detail `detail` (1..14): the
|
||||
/// less detail, the wider the brush.
|
||||
fn water_brush(detail: f64) -> f64 {
|
||||
0.5 * (15.0 - detail.clamp(1.0, 14.0))
|
||||
}
|
||||
|
||||
/// The σ (points) of the softening that finds Watercolor's edges.
|
||||
const WATER_EDGE: f64 = 0.6;
|
||||
|
||||
/// How far (document units) Watercolor reaches for Brush Detail `detail`.
|
||||
pub(super) fn watercolor_reach(detail: f64) -> f64 {
|
||||
water_brush(detail) + 3.0 * WATER_EDGE + MARGIN
|
||||
}
|
||||
|
||||
/// Smudge Stick: the dark areas smudged along short diagonal strokes `stroke` (0..10) long, the
|
||||
/// light areas smudged with longer strokes and lightened towards white as strongly as `intensity`
|
||||
/// (0..10) says, so they lose their detail; `highlight` (0..20) widens the area counted as light.
|
||||
pub(super) fn smudge_stick(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke: f64, highlight: f64, intensity: f64) {
|
||||
if ![stroke, highlight, intensity].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = smudge_length(stroke);
|
||||
let (short, long) = (Smear::new(px, w, h, &place, len), Smear::new(px, w, h, &place, 2.0 * len));
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
// Tones above the threshold count as light; a wider highlight area lowers it.
|
||||
let threshold = 0.9 - 0.5 * (highlight / 20.0).clamp(0.0, 1.0);
|
||||
let lift = (0.7 * intensity / 10.0).clamp(0.0, 0.7) as f32;
|
||||
let row = (0.25 * len).max(1.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let own = straight(*p);
|
||||
let paint = |smear: &Smear, salt: u64| {
|
||||
let (c, a) = unpremultiply(smear.stroke(salt, q, RIGHT, row));
|
||||
if a > 0.0 { c } else { own }
|
||||
};
|
||||
// Bristle marks along the dark strokes.
|
||||
let bristle = (1.0 + 0.08 * streak(SALT_SMUDGE + 2, q, RIGHT, len, 0.5)) as f32;
|
||||
let dark = paint(&short, SALT_SMUDGE).map(|v| v * bristle);
|
||||
let light = paint(&long, SALT_SMUDGE + 1);
|
||||
let t = ramp((tones.get(x, y) - threshold) / 0.2 + 0.5) as f32;
|
||||
let bright = light.map(|v| v + (1.0 - v) * lift);
|
||||
*p = premultiply(mix(dark, bright, t), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sponge: the object as if dabbed with a sponge, soft blobs of lighter and darker colour about
|
||||
/// `brush` (0..10) large; `definition` (0..25) sets how strongly they contrast and `smoothness`
|
||||
/// (1..15) how soft their edges are.
|
||||
pub(super) fn sponge(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush: f64, definition: f64, smoothness: f64) {
|
||||
if ![brush, definition, smoothness].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let mut soft: Vec<Px16> = to16(px);
|
||||
gaussian(&mut soft, w, sponge_softness(brush, smoothness) / place.px);
|
||||
let pore = sponge_pore(brush);
|
||||
let edge = 0.12 + 0.03 * smoothness.clamp(1.0, 15.0);
|
||||
let amount = (definition / 25.0).clamp(0.0, 1.0);
|
||||
let gain = 1.0 + 1.2 * amount;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let alpha = f32::from(p[3]) / 255.0;
|
||||
let own = straight(*p);
|
||||
let (c, a) = unpremultiply(soft.get(y * w + x).map_or([0.0; 4], |s| s.map(|v| f32::from(v) / 65535.0)));
|
||||
let c = if a > 0.0 { c } else { own };
|
||||
let q = place.at(x, y);
|
||||
let n = 0.65 * smooth_noise(SALT_SPONGE, q, pore) + 0.35 * smooth_noise(SALT_SPONGE + 1, q, 0.45 * pore);
|
||||
// -1 inside a dark pore, 1 on a light bridge between pores.
|
||||
let blob = 2.0 * ramp(0.5 + n / (2.0 * edge)) - 1.0;
|
||||
let l = f64::from(luma(c));
|
||||
let target = (0.5 + (l + 0.55 * amount * blob - 0.5) * gain).clamp(0.0, 1.0);
|
||||
// Darker towards black, lighter towards white: the hue stays.
|
||||
let out = if target <= l {
|
||||
let k = if l > 1e-3 { (target / l) as f32 } else { 0.0 };
|
||||
c.map(|v| v * k)
|
||||
} else {
|
||||
let k = ((target - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v + (1.0 - v) * k)
|
||||
};
|
||||
*p = premultiply(out, alpha);
|
||||
});
|
||||
}
|
||||
|
||||
/// Watercolor: the object's detail simplified by a brush whose size `detail` (1..14) sets (the
|
||||
/// larger, the more detail stays), the colour saturated and its pigment pooled where tones change
|
||||
/// at edges, the shadows deepened as strongly as `shadow` (0..10) says, and `texture` (1..3)
|
||||
/// grains of paper showing through.
|
||||
pub(super) fn watercolor(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, shadow: f64, texture: u32) {
|
||||
if ![detail, shadow].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let coverage: Vec<u8> = px.iter().map(|p| p[3]).collect();
|
||||
// Simplify: average within areas of similar colour, keeping the bolder edges; a coarser brush
|
||||
// (less detail) also merges colours further apart.
|
||||
blur::smart(px, w, h, water_brush(detail) / place.px, 20.0 + 6.0 * (14.0 - detail.clamp(1.0, 14.0)), 7);
|
||||
let found = edges(px, w, h, &place, WATER_EDGE);
|
||||
let shadow = shadow.clamp(0.0, 10.0);
|
||||
let paper = 0.02 * f64::from(texture.clamp(1, 3));
|
||||
let grain = 1.0 + 0.5 * f64::from(texture.clamp(1, 3));
|
||||
place.each(px, |x, y, p| {
|
||||
let alpha = coverage.get(y * w + x).copied().unwrap_or(0);
|
||||
if alpha == 0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let c = straight(*p);
|
||||
let e = (2.0 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0))).clamp(0.0, 1.0) as f32;
|
||||
let l = luma(c);
|
||||
// Saturated colour and darker pigment where the tones change.
|
||||
let c = c.map(|v| (l + (v - l) * (1.0 + 0.9 * e)) * (1.0 - 0.12 * e));
|
||||
let deep = ((1.0 - l / 0.5).clamp(0.0, 1.0) * 0.07 * shadow as f32).min(0.8);
|
||||
let q = place.at(x, y);
|
||||
let n = 0.6 * smooth_noise(SALT_WATER, q, grain) + 0.4 * smooth_noise(SALT_WATER + 1, q, 0.5 * grain);
|
||||
let n = (paper * n) as f32;
|
||||
*p = premultiply(c.map(|v| v * (1.0 - deep) + n), f32::from(alpha) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,46 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! 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; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
//! 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 super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||
|
||||
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};
|
||||
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.
|
||||
const CHROME_LIGHT: Vec2 = Vec2::new(-FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
|
||||
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
|
||||
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
|
||||
|
||||
fn ramp(t: f64) -> f64 {
|
||||
t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
blur_plane(&mut tone, w, h, s);
|
||||
blur_plane(&mut cover, w, h, s);
|
||||
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
|
||||
let norm = if norm.is_finite() { norm } else { 0.0 };
|
||||
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
|
||||
}
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
@@ -69,3 +103,380 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Note Paper: the object embossed on fibrous paper. `balance` (0..50) shifts tones toward
|
||||
/// highlights or shadows around the middle; `graininess` (0..10) adds paper fibre; `relief`
|
||||
/// (0..50, points) is how strongly the tones stand out in the light. Coverage is kept.
|
||||
pub(super) fn note_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, graininess: f64, relief: f64) {
|
||||
if !(balance.is_finite() && graininess.is_finite() && relief.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let height: Vec<f32> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p[3] == 0 {
|
||||
0.0
|
||||
} else {
|
||||
let mut l = f64::from(luma(straight(*p)));
|
||||
l += (balance - 25.0) / 125.0;
|
||||
l.clamp(0.0, 1.0) as f32
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sample = |x: i32, y: i32| -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
|
||||
return 0.0;
|
||||
}
|
||||
height.get(y as usize * w + x as usize).copied().unwrap_or(0.0)
|
||||
};
|
||||
let emboss = relief / 50.0;
|
||||
let grain = graininess / 10.0;
|
||||
let light = (-0.6_f64, -0.6, 0.8);
|
||||
let ln = (light.0 * light.0 + light.1 * light.1 + light.2 * light.2).sqrt().max(1e-9);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let xi = x as i32;
|
||||
let yi = y as i32;
|
||||
let h0 = height.get(y * w + x).copied().unwrap_or(0.0);
|
||||
let dx = (sample(xi + 1, yi) - sample(xi - 1, yi)) as f64;
|
||||
let dy = (sample(xi, yi + 1) - sample(xi, yi - 1)) as f64;
|
||||
let dz = emboss * 0.12;
|
||||
let nx = -dx;
|
||||
let ny = -dy;
|
||||
let nz = dz;
|
||||
let nn = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
|
||||
let shade = ((nx * light.0 + ny * light.1 + nz * light.2) / (nn * ln) * 0.5 + 0.5).clamp(0.0, 1.0);
|
||||
let tone = (0.35 + 0.65 * f64::from(h0)) * shade;
|
||||
let q = place.at(x, y);
|
||||
let fibre = if grain > 0.0 { value_noise(0x4e4f_5450, q.x * 0.35, q.y * 0.35) * grain * 0.18 } else { 0.0 };
|
||||
let v = (tone + fibre).clamp(0.0, 1.0) as f32;
|
||||
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
|
||||
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
|
||||
/// chalk and charcoal on white paper, coverage kept.
|
||||
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
|
||||
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
|
||||
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let tone = tones.get(x, y);
|
||||
let dark = (1.0 - tone).clamp(0.0, 1.0);
|
||||
let light = tone.clamp(0.0, 1.0);
|
||||
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
|
||||
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
|
||||
let charcoal_mask = ramp((dark - 0.3) / 0.22)
|
||||
* charcoal_amt
|
||||
* (0.35 + 0.65 * edge)
|
||||
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
|
||||
* (0.6 + 0.4 * charcoal_grain);
|
||||
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
|
||||
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
|
||||
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
|
||||
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
|
||||
let mut grey = luma(straight(*p));
|
||||
grey *= 1.0 - down;
|
||||
grey += (1.0 - grey) * up;
|
||||
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// How far (document units) Chrome reaches for `smoothness` (0..10): 3 σ of the height-field blur
|
||||
/// plus the Sobel neighbours.
|
||||
pub(super) fn chrome_reach(smoothness: f64) -> f64 {
|
||||
let smoothness = smoothness.clamp(0.0, 10.0);
|
||||
3.0 * (0.4 + 0.55 * smoothness) + 1.0
|
||||
}
|
||||
|
||||
/// Chrome: the object as a polished chrome surface lit from the top left. `detail` (0..10) adds
|
||||
/// finer relief; `smoothness` (0..10) softens the height field before the highlights and shadows
|
||||
/// are drawn. Coverage is kept.
|
||||
pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64) {
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let detail = detail.clamp(0.0, 10.0);
|
||||
let smoothness = smoothness.clamp(0.0, 10.0);
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = (0.4 + smoothness * 0.55) / place.px.max(1e-9);
|
||||
let cell = (14.0 - detail * 1.2).max(1.5);
|
||||
let bump = (detail / 10.0) * 0.4;
|
||||
let strength = 2.5 + detail * 0.35;
|
||||
let mut height: Vec<f32> = Vec::with_capacity(w * h);
|
||||
let mut alpha: Vec<f32> = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = px[y * w + x];
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
alpha.push(a);
|
||||
if a <= 0.0 {
|
||||
height.push(0.0);
|
||||
continue;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let base = f64::from(luma(straight(p)));
|
||||
let n = if bump > 0.0 { bump * smooth_noise(SALT_CHROME, q, cell) } else { 0.0 };
|
||||
height.push((base + n).clamp(0.0, 1.0) as f32);
|
||||
}
|
||||
}
|
||||
blur_plane(&mut height, w, h, sigma);
|
||||
blur_plane(&mut alpha, w, h, sigma);
|
||||
let tone: Vec<f64> = height.iter().zip(&alpha).map(|(h, a)| if *a > 1e-4 { f64::from(*h / *a).clamp(0.0, 1.0) } else { 0.0 }).collect();
|
||||
let px_unit = place.px.max(1e-9);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let at = |dx: isize, dy: isize| {
|
||||
let (xx, yy) = ((x as isize + dx).clamp(0, w as isize - 1) as usize, (y as isize + dy).clamp(0, h as isize - 1) as usize);
|
||||
tone[yy * w + xx]
|
||||
};
|
||||
let h0 = tone[y * w + x];
|
||||
let gx = 0.5 * (at(1, 0) - at(-1, 0)) / px_unit;
|
||||
let gy = 0.5 * (at(0, 1) - at(0, -1)) / px_unit;
|
||||
let face = -(gx * CHROME_LIGHT.x + gy * CHROME_LIGHT.y);
|
||||
let spec = (face * strength + (h0 - 0.5) * 1.5).tanh();
|
||||
let grey = (0.5 + 0.5 * spec).clamp(0.0, 1.0);
|
||||
*p = premultiply([grey as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
|
||||
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
|
||||
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
|
||||
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
|
||||
}
|
||||
|
||||
/// How a surface whose height rises along `slope` faces a light from `toward`.
|
||||
fn facing(slope: Vec2, toward: Vec2) -> f64 {
|
||||
-slope.dot(toward)
|
||||
}
|
||||
|
||||
/// The σ (document units) of Plaster's height blur at `smoothness` (1..15).
|
||||
fn plaster_sigma_pt(smoothness: f64) -> f64 {
|
||||
let smoothness = smoothness.clamp(1.0, 15.0);
|
||||
0.15 + 0.85 * (smoothness - 1.0) / 14.0
|
||||
}
|
||||
|
||||
/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15).
|
||||
pub(super) fn plaster_reach(smoothness: f64) -> f64 {
|
||||
if !smoothness.is_finite() {
|
||||
return 3.0 * plaster_sigma_pt(8.0) + 0.5;
|
||||
}
|
||||
3.0 * plaster_sigma_pt(smoothness) + 0.5
|
||||
}
|
||||
|
||||
/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and
|
||||
/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values;
|
||||
/// `smoothness` (1..15) softens the relief. Coverage is kept.
|
||||
pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) {
|
||||
if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0)
|
||||
|| w == 0
|
||||
|| h == 0
|
||||
|| w.checked_mul(h) != Some(px.len())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let image_balance = image_balance.clamp(1.0, 50.0);
|
||||
let smoothness = smoothness.clamp(1.0, 15.0);
|
||||
let bias = (image_balance - 1.0) / 49.0;
|
||||
let mut height: Vec<f32> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let dark = (1.0 - luma(straight(*p))) * a;
|
||||
(dark - bias as f32 * 0.65).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px);
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let step = (0.35f64).max(0.5 * place.px);
|
||||
let strength = 0.55;
|
||||
let toward = light.toward();
|
||||
let at = |x: usize, y: usize, dx: i32, dy: i32| {
|
||||
let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
|
||||
height[yy * w + xx]
|
||||
};
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let h = height[y * w + x];
|
||||
let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1));
|
||||
let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step);
|
||||
let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step;
|
||||
let shade = strength * (facing(slope, toward) + ridge);
|
||||
*p = premultiply(lit(straight(*p), shade), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
|
||||
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
|
||||
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
|
||||
/// kept.
|
||||
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
|
||||
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
|
||||
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
|
||||
let fg = foreground.clamp(0.0, 50.0) / 50.0;
|
||||
let bg = background.clamp(0.0, 50.0) / 50.0;
|
||||
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
|
||||
const SALT: u64 = 0x5245_7469;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
|
||||
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
|
||||
let fine = match (cell(q.x), cell(q.y)) {
|
||||
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
|
||||
_ => 0.5,
|
||||
};
|
||||
let grain = 0.55 * coarse + 0.45 * fine;
|
||||
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
|
||||
*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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp. Original filters built from public
|
||||
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html> (the Sketch effects
|
||||
//! redraw the object in the foreground and background colours, black on white here; Bas Relief
|
||||
//! carves low relief lit from a direction, Charcoal draws bold edges and sketches the midtones in
|
||||
//! diagonal strokes, Photocopy copies only the edges of large dark areas and takes the midtones to
|
||||
//! black or white, Stamp simplifies the image to solid black and white as a rubber stamp would).
|
||||
//! Visual approximations, not reproductions of a proprietary algorithm; the ranges and defaults
|
||||
//! are chosen for VectorCraft.
|
||||
//!
|
||||
//! All four keep the object's coverage, so they paint nothing outside it. Their sizes are in
|
||||
//! document points, whatever the raster's resolution.
|
||||
|
||||
use std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
use super::pixelate::{Place, cell, value_noise};
|
||||
use super::texture::{Light, Tones, premultiply};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
|
||||
const SALT_STROKES: u64 = 440;
|
||||
|
||||
/// A pixel's size (points) at 72 ppi: how far the neighbours of a gradient add to a filter's reach.
|
||||
const MARGIN: f64 = 1.0;
|
||||
|
||||
/// Redraw `p` as ink `ink` (0..1) of black on white paper, keeping its coverage.
|
||||
fn paint(p: &mut [u8; 4], ink: f64) {
|
||||
let v = if ink.is_finite() { (1.0 - ink).clamp(0.0, 1.0) as f32 } else { 1.0 };
|
||||
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
|
||||
}
|
||||
|
||||
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1 (0 when `t` is not a number).
|
||||
fn ramp(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// The tone at (`x`, `y`) and its gradient per pixel, from central differences.
|
||||
fn gradient(tones: &Tones, x: usize, y: usize) -> (f64, f64, f64) {
|
||||
let gx = 0.5 * (tones.get(x + 1, y) - tones.get(x.saturating_sub(1), y));
|
||||
let gy = 0.5 * (tones.get(x, y + 1) - tones.get(x, y.saturating_sub(1)));
|
||||
(tones.get(x, y), gx, gy)
|
||||
}
|
||||
|
||||
/// The σ (points) Bas Relief softens the object by, for Detail `detail` (1..15) and Smoothness
|
||||
/// `smoothness` (1..15).
|
||||
fn relief_softness(detail: f64, smoothness: f64) -> f64 {
|
||||
0.2 + 0.15 * smoothness.clamp(1.0, 15.0) + 0.1 * (15.0 - detail.clamp(1.0, 15.0))
|
||||
}
|
||||
|
||||
/// How far (document units) Bas Relief reaches: the softened tones and their neighbours.
|
||||
pub(super) fn relief_reach(detail: f64, smoothness: f64) -> f64 {
|
||||
3.0 * relief_softness(detail, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// Bas Relief: the object carved in low relief and lit from `light`. Surfaces facing the light turn
|
||||
/// white, those turned away black, flat areas grey with a hint of the object's own tones; `detail`
|
||||
/// (1..15) keeps finer features and `smoothness` (1..15) rounds the carving.
|
||||
pub(super) fn bas_relief(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64, light: Light) {
|
||||
if !(detail.is_finite() && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = relief_softness(detail, smoothness);
|
||||
let tones = Tones::new(px, w, h, sigma / place.px);
|
||||
let toward = light.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let (tone, slope) = tones.slope(x, y, &place);
|
||||
// A step from black to white, softened by σ, rises 0.4 per σ at its steepest.
|
||||
let lit = -slope.dot(toward) * sigma;
|
||||
paint(p, 1.0 - (0.5 + 0.2 * (tone - 0.5) + 2.5 * lit));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) Charcoal softens the object by to find its edges, for Detail `detail` (0..5).
|
||||
fn charcoal_softness(detail: f64) -> f64 {
|
||||
0.25 + 0.25 * (5.0 - detail.clamp(0.0, 5.0)) + 0.25
|
||||
}
|
||||
|
||||
/// The σ (points) that thickens Charcoal's edges, for Thickness `thickness` (1..7).
|
||||
fn charcoal_thickening(thickness: f64) -> f64 {
|
||||
0.35 * thickness.clamp(1.0, 7.0)
|
||||
}
|
||||
|
||||
/// The distance (points) between the strokes sketching Charcoal's midtones.
|
||||
fn charcoal_spacing(thickness: f64) -> f64 {
|
||||
1.5 + 0.5 * thickness.clamp(1.0, 7.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Charcoal reaches: the edges, their thickening and the strokes' tones.
|
||||
pub(super) fn charcoal_reach(thickness: f64, detail: f64) -> f64 {
|
||||
(3.0 * (charcoal_softness(detail) + charcoal_thickening(thickness)) + 2.0 * MARGIN).max(2.0 * charcoal_spacing(thickness) + MARGIN)
|
||||
}
|
||||
|
||||
/// Charcoal: bold, `thickness` (1..7) wide edges, as fine as `detail` (0..5) says, and the
|
||||
/// midtones sketched in diagonal strokes; `balance` (0..100) sets how far into the lights the
|
||||
/// charcoal reaches (50 is even).
|
||||
pub(super) fn charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, detail: f64, balance: f64) {
|
||||
if !(thickness.is_finite() && detail.is_finite() && balance.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = charcoal_softness(detail);
|
||||
let spacing = charcoal_spacing(thickness);
|
||||
let bias = (balance.clamp(0.0, 100.0) - 50.0) / 100.0 * 0.8;
|
||||
// The edges: how much the softened tones change across σ, 1 for a step from black to white.
|
||||
let edge_tones = Tones::new(px, w, h, sigma / place.px);
|
||||
let mut edge: Vec<f32> = (0..w * h)
|
||||
.map(|k| {
|
||||
let (_, gx, gy) = gradient(&edge_tones, k % w, k / w);
|
||||
let contrast = gx.hypot(gy) / place.px * sigma / 0.4;
|
||||
let weakest = 0.35 - 0.2 * bias / 0.4;
|
||||
ramp((contrast - weakest) / 0.3) as f32
|
||||
})
|
||||
.collect();
|
||||
blur_plane(&mut edge, w, h, charcoal_thickening(thickness) / place.px);
|
||||
// The strokes' tones are softer than the edges', so the strokes don't follow every speck.
|
||||
let tones = Tones::new(px, w, h, 0.5 * spacing / place.px);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let bold = edge.get(y * w + x).map_or(0.0, |e| ((f64::from(*e) - 0.3) / 0.3).clamp(0.0, 1.0));
|
||||
let dark = 1.0 - tones.get(x, y);
|
||||
let share = ((dark + bias - 0.2) / 0.6).clamp(0.0, 1.0);
|
||||
let q = place.at(x, y);
|
||||
// Across the strokes, which run rising to the right, and along them.
|
||||
let (across, along) = ((q.x + q.y) * FRAC_1_SQRT_2 / spacing, (q.x - q.y) * FRAC_1_SQRT_2 / spacing);
|
||||
let stroke = match (share > 0.0, cell(across)) {
|
||||
(true, Some(line)) => {
|
||||
let f = across - line as f64;
|
||||
// Each stroke breaks and swells along its length.
|
||||
let swell = (0.6 + 0.8 * value_noise(SALT_STROKES, along / 6.0, line as f64)).clamp(0.0, 1.2);
|
||||
let reach = (share * swell).min(1.0);
|
||||
let soft = 2.0 * place.px / spacing;
|
||||
(((reach - (2.0 * f - 1.0).abs()) / soft + 0.5).clamp(0.0, 1.0)) * f64::from(reach > 0.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
paint(p, bold.max(stroke));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) of Photocopy's narrow softening for Detail `detail` (1..24): the less detail,
|
||||
/// the wider.
|
||||
fn copy_softness(detail: f64) -> f64 {
|
||||
0.15 * (25.0 - detail.clamp(1.0, 24.0))
|
||||
}
|
||||
|
||||
/// How much wider Photocopy's second softening is than its first.
|
||||
const COPY_WIDER: f64 = 3.0;
|
||||
|
||||
/// How far (document units) Photocopy reaches: the wide softening.
|
||||
pub(super) fn copy_reach(detail: f64) -> f64 {
|
||||
3.0 * COPY_WIDER * copy_softness(detail) + MARGIN
|
||||
}
|
||||
|
||||
/// Photocopy: black where a pixel is darker than its surroundings, white elsewhere. Large dark
|
||||
/// areas copy only around their edges and midtones go to black or white. `detail` (1..24) keeps
|
||||
/// finer features and `darkness` (1..50) copies more of the dark.
|
||||
pub(super) fn photocopy(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, darkness: f64) {
|
||||
if !(detail.is_finite() && darkness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = copy_softness(detail) / place.px;
|
||||
let narrow = Tones::new(px, w, h, sigma);
|
||||
let wide = Tones::new(px, w, h, COPY_WIDER * sigma);
|
||||
// How much darker than its surroundings a pixel must be to print.
|
||||
let threshold = 0.02 + 0.10 * (1.0 - (darkness.clamp(1.0, 50.0) - 1.0) / 49.0);
|
||||
let soft = 0.02;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
paint(p, ((wide.get(x, y) - narrow.get(x, y) - threshold) / soft + 0.5).clamp(0.0, 1.0));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) Stamp softens the object by for Smoothness `smoothness` (1..50).
|
||||
fn stamp_softness(smoothness: f64) -> f64 {
|
||||
0.1 * smoothness.clamp(1.0, 50.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Stamp reaches: the softened tones and their neighbours.
|
||||
pub(super) fn stamp_reach(smoothness: f64) -> f64 {
|
||||
3.0 * stamp_softness(smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// Stamp: the object as solid black and white, like a rubber stamp. `balance` (0..50) sets how much
|
||||
/// of the object prints black and `smoothness` (1..50) rounds the shapes.
|
||||
pub(super) fn stamp(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, smoothness: f64) {
|
||||
if !(balance.is_finite() && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, stamp_softness(smoothness) / place.px);
|
||||
let threshold = 0.2 + 0.6 * balance.clamp(0.0, 50.0) / 50.0;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let (tone, gx, gy) = gradient(&tones, x, y);
|
||||
// A one pixel wide edge, whatever the softening.
|
||||
paint(p, ((threshold - tone) / gx.hypot(gy).max(0.01) + 0.5).clamp(0.0, 1.0));
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
@@ -1202,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();
|
||||
@@ -1406,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]
|
||||
@@ -1978,6 +2023,47 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.chalkCharcoal", json!({})), PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
|
||||
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
|
||||
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
|
||||
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert!(f.reach().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &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");
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(p[0], p[1]);
|
||||
assert_eq!(p[1], p[2]);
|
||||
}
|
||||
assert_ne!(out, src);
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
@@ -1985,11 +2071,57 @@ 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())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_pen_params_take_defaults_and_stay_in_range() {
|
||||
use super::STROKE_DIRECTIONS;
|
||||
assert_eq!(fx("sketch.graphicPen", json!({})), PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal });
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})),
|
||||
PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.graphicPen").unwrap().params;
|
||||
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
|
||||
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 99})),
|
||||
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphic Pen on a mid-grey field draws both black ink and white paper.
|
||||
#[test]
|
||||
fn graphic_pen_draws_ink_and_paper_on_grey() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |_, _| grey(32));
|
||||
let mut out = src.clone();
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h));
|
||||
let lumas: Vec<u8> = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l)));
|
||||
assert!(min < 80 && max > 200, "min {min} max {max}");
|
||||
assert_ne!(out, src, "the filter should change the raster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
@@ -2013,6 +2145,73 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_paper_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.notePaper", json!({})), PixelFx::NotePaper { balance: 25.0, graininess: 0.0, relief: 4.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.notePaper", json!({"imageBalance": -1, "graininess": 20, "relief": 100})),
|
||||
PixelFx::NotePaper { balance: 0.0, graininess: 10.0, relief: 50.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.notePaper").unwrap().params;
|
||||
assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}");
|
||||
let f = fx("sketch.notePaper", json!({"relief": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Note Paper embosses a horizontal gradient and graininess adds fibre.
|
||||
#[test]
|
||||
fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
|
||||
let (w, h) = (32, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
|
||||
let flat = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 0, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
let embossed = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 50, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
assert_ne!(flat, embossed, "relief changes the emboss");
|
||||
let grainy = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 0, "graininess": 10})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
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() {
|
||||
@@ -2057,6 +2256,41 @@ fn halftone_pattern_type_changes_the_pattern() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.plaster", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaster_params_take_defaults_and_stay_in_range() {
|
||||
use super::texture::Light;
|
||||
assert_eq!(fx("sketch.plaster", json!({})), PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top });
|
||||
assert_eq!(
|
||||
fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})),
|
||||
PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight }
|
||||
);
|
||||
assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({})));
|
||||
let doc = crate::effect_info("sketch.plaster").unwrap().params;
|
||||
assert!(doc.contains("\"bottomRight\""), "{doc}");
|
||||
let f = fx("sketch.plaster", json!({"smoothness": 24}));
|
||||
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
|
||||
}
|
||||
|
||||
/// Plaster changes the image and different light directions give different results.
|
||||
#[test]
|
||||
fn plaster_light_direction_shifts_shading() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8));
|
||||
let s = space(w, h);
|
||||
let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s);
|
||||
assert_ne!(base, src, "plaster changes opaque pixels");
|
||||
let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s);
|
||||
let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s);
|
||||
assert_ne!(left, right, "light direction changes the relief shading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
@@ -2094,6 +2328,146 @@ fn halftone_draws_in_black_and_white_only() {
|
||||
}
|
||||
}
|
||||
|
||||
fn chrome(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chrome", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.chrome", json!({})), PixelFx::Chrome { detail: 4.0, smoothness: 4.0 });
|
||||
assert_eq!(fx("sketch.chrome", json!({"detail": -1, "smoothness": 99})), PixelFx::Chrome { detail: 0.0, smoothness: 10.0 });
|
||||
assert_eq!(fx("sketch.chrome", json!({"detail": "x"})), fx("sketch.chrome", json!({})));
|
||||
let doc = crate::effect_info("sketch.chrome").unwrap().params;
|
||||
assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}");
|
||||
let f = fx("sketch.chrome", json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0)));
|
||||
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0)));
|
||||
}
|
||||
|
||||
/// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern.
|
||||
#[test]
|
||||
fn chrome_draws_polished_greyscale_relief() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |x, y| {
|
||||
let t = ((x + y) * 4) as u8;
|
||||
[t, t / 2, 255 - t, 255]
|
||||
});
|
||||
let soft = chrome(json!({"detail": 0, "smoothness": 8}), &src, w, h, &space(w, h));
|
||||
let sharp = chrome(json!({"detail": 10, "smoothness": 0}), &src, w, h, &space(w, h));
|
||||
assert_ne!(soft, sharp);
|
||||
for out in [&soft, &sharp] {
|
||||
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], "greyscale {p:?}");
|
||||
}
|
||||
assert!(share(out, |p| p[0] > 10 && p[0] < 245) > 0.05, "midtones");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
|
||||
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
|
||||
);
|
||||
let f = fx("sketch.reticulation", json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
let out = reticulation(json!({}), &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]), "{p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_density_changes_the_grain() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
|
||||
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
|
||||
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()
|
||||
@@ -2111,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})),
|
||||
@@ -2128,9 +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] }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
|
||||
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.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]
|
||||
@@ -2145,8 +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");
|
||||
@@ -2168,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:?}"));
|
||||
@@ -2176,6 +2682,63 @@ 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);
|
||||
let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8));
|
||||
let mut none = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h));
|
||||
assert_eq!(none, src);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out != src, "defaults brighten some pixels");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let mut weak = src.clone();
|
||||
let mut strong = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h));
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h));
|
||||
let lift =
|
||||
|a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::<i32>();
|
||||
assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
let (w, h) = (64, 24);
|
||||
@@ -2190,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.
|
||||
@@ -2203,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);
|
||||
@@ -2237,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);
|
||||
@@ -2274,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));
|
||||
@@ -2289,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());
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Artistic › Smudge Stick, Sponge and Watercolor.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(w / 2.0, h / 2.0),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * w.hypot(h),
|
||||
}
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx(id, p).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// The mean of channel 0 over the columns `x0..x1`.
|
||||
fn mean(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
|
||||
let rows = d.len() / 4 / w;
|
||||
let sum: f64 = (0..rows).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f64::from(d[(y * w + x) * 4])).sum();
|
||||
sum / (rows * (x1 - x0)) as f64
|
||||
}
|
||||
|
||||
/// The summed difference between horizontal neighbours of channel 0 over columns `x0..x1`.
|
||||
fn roughness(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
|
||||
let rows = d.len() / 4 / w;
|
||||
(0..rows)
|
||||
.flat_map(|y| (x0..x1 - 1).map(move |x| (x, y)))
|
||||
.map(|(x, y)| (f64::from(d[(y * w + x) * 4]) - f64::from(d[(y * w + x + 1) * 4])).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn spread(d: &[u8]) -> f64 {
|
||||
let v: Vec<f64> = d.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).collect();
|
||||
let m = v.iter().sum::<f64>() / v.len() as f64;
|
||||
(v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
|
||||
fn survives(id: &str, params: &[serde_json::Value]) {
|
||||
let (w, h) = (9, 7);
|
||||
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
|
||||
for p in params {
|
||||
for s in [tiny, huge, nan, space(w, h)] {
|
||||
let mut out = src.clone();
|
||||
fx(id, p.clone()).apply(&mut out, w, h, &s);
|
||||
assert_eq!(out.len(), src.len());
|
||||
}
|
||||
}
|
||||
// A size that doesn't match, and an empty raster, do nothing.
|
||||
let mut odd = vec![1u8; 10];
|
||||
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
|
||||
assert_eq!(odd, vec![1u8; 10]);
|
||||
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smudge_stick_smudges_the_dark_and_lightens_the_light() {
|
||||
assert_eq!(fx("artistic.smudgeStick", json!({})), PixelFx::SmudgeStick { stroke: 2.0, highlight: 12.0, intensity: 10.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.smudgeStick", json!({"strokeLength": 1e308, "highlightArea": -5, "intensity": "x"})),
|
||||
PixelFx::SmudgeStick { stroke: 10.0, highlight: 0.0, intensity: 10.0 }
|
||||
);
|
||||
// A fine checkerboard of dark tones on the left, of light tones on the right, and a half
|
||||
// transparent bottom.
|
||||
let (w, h) = (64, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let on = (x + y) % 2 == 0;
|
||||
let v = match (x < 32, on) {
|
||||
(true, true) => 20,
|
||||
(true, false) => 70,
|
||||
(false, true) => 190,
|
||||
(false, false) => 240,
|
||||
};
|
||||
if y < 40 { grey(v) } else { [v / 2, v / 2, v / 2, 128] }
|
||||
});
|
||||
let out = run("artistic.smudgeStick", json!({}), &src, w, h);
|
||||
assert_eq!(out, run("artistic.smudgeStick", json!({}), &src, w, h), "draws the same twice");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
|
||||
// The dark detail is smudged away, the light areas brighter.
|
||||
assert!(roughness(&out, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28));
|
||||
assert!(mean(&out, w, 36, 60) > mean(&src, w, 36, 60) + 10.0);
|
||||
// Intensity 0 leaves the light areas unlightened.
|
||||
let flat = run("artistic.smudgeStick", json!({"intensity": 0}), &src, w, h);
|
||||
assert!(mean(&flat, w, 36, 60) < mean(&out, w, 36, 60) - 5.0);
|
||||
survives(
|
||||
"artistic.smudgeStick",
|
||||
&[json!({}), json!({"strokeLength": 1e308, "highlightArea": 1e308, "intensity": 1e308}), json!({"strokeLength": "NaN"})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sponge_dabs_contrasting_blobs_and_keeps_the_hue() {
|
||||
assert_eq!(fx("artistic.sponge", json!({})), PixelFx::Sponge { brush: 2.0, definition: 12.0, smoothness: 5.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.sponge", json!({"brushSize": -1, "definition": 1e308, "smoothness": 0})),
|
||||
PixelFx::Sponge { brush: 0.0, definition: 25.0, smoothness: 1.0 }
|
||||
);
|
||||
let (w, h) = (80, 60);
|
||||
let flat = image(w, h, |_, y| if y < 50 { [200, 90, 60, 255] } else { [100, 45, 30, 128] });
|
||||
let out = run("artistic.sponge", json!({"definition": 25}), &flat, w, h);
|
||||
assert_eq!(out, run("artistic.sponge", json!({"definition": 25}), &flat, w, h), "draws the same twice");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(flat.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
|
||||
// A flat colour breaks up into lighter and darker blobs of the same hue.
|
||||
let top = &out[..w * 50 * 4];
|
||||
assert!(spread(top) > 15.0, "{}", spread(top));
|
||||
assert!(top.as_chunks::<4>().0.iter().all(|p| p[0] >= p[1] && p[1] >= p[2]), "red stays the strongest channel");
|
||||
// Definition 0 leaves a flat colour as it is; more definition, more contrast.
|
||||
let none = run("artistic.sponge", json!({"definition": 0}), &flat, w, h);
|
||||
assert!(spread(&none[..w * 50 * 4]) < 1.0);
|
||||
let some = run("artistic.sponge", json!({"definition": 8}), &flat, w, h);
|
||||
assert!(spread(&some[..w * 50 * 4]) < spread(top));
|
||||
survives("artistic.sponge", &[json!({}), json!({"brushSize": 1e308, "definition": 1e308, "smoothness": 1e308}), json!({"brushSize": "NaN"})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watercolor_simplifies_detail_and_saturates_edges() {
|
||||
assert_eq!(fx("artistic.watercolor", json!({})), PixelFx::Watercolor { detail: 9.0, shadow: 1.0, texture: 1 });
|
||||
assert_eq!(
|
||||
fx("artistic.watercolor", json!({"brushDetail": 99, "shadowIntensity": -1, "texture": 2.6})),
|
||||
PixelFx::Watercolor { detail: 14.0, shadow: 0.0, texture: 3 }
|
||||
);
|
||||
// Noisy grey on the left, a step from light red to dark blue on the right, a transparent row.
|
||||
let (w, h) = (64, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
if y >= 44 {
|
||||
return [0; 4];
|
||||
}
|
||||
if x < 32 {
|
||||
grey(if (x * 7 + y * 13) % 5 < 2 { 90 } else { 150 })
|
||||
} else if x < 48 {
|
||||
[200, 120, 120, 255]
|
||||
} else {
|
||||
[60, 70, 110, 255]
|
||||
}
|
||||
});
|
||||
let few = run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h);
|
||||
let many = run("artistic.watercolor", json!({"brushDetail": 14}), &src, w, h);
|
||||
assert_eq!(few, run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h), "draws the same twice");
|
||||
assert!(few.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept, nothing painted outside");
|
||||
assert!(roughness(&few, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28), "less detail, simpler");
|
||||
assert!(roughness(&few, w, 4, 28) < roughness(&many, w, 4, 28));
|
||||
// Colour is saturated where the tones change, and shadows deepen with Shadow Intensity.
|
||||
let chroma = |d: &[u8], x: usize, y: usize| {
|
||||
let i = (y * w + x) * 4;
|
||||
f64::from(d[i].max(d[i + 1]).max(d[i + 2])) - f64::from(d[i].min(d[i + 1]).min(d[i + 2]))
|
||||
};
|
||||
assert!(chroma(&few, 47, 20) > chroma(&src, 47, 20) || chroma(&few, 48, 20) > chroma(&src, 48, 20));
|
||||
assert!(mean(&run("artistic.watercolor", json!({"shadowIntensity": 10}), &src, w, h), w, 4, 28) < mean(&few, w, 4, 28));
|
||||
// Texture adds paper grain to a flat colour.
|
||||
let flat = image(w, h, |_, _| grey(128));
|
||||
let (t1, t3) = (run("artistic.watercolor", json!({"texture": 1}), &flat, w, h), run("artistic.watercolor", json!({"texture": 3}), &flat, w, h));
|
||||
assert!(spread(&t3) > spread(&t1) && spread(&t1) > 0.2);
|
||||
survives(
|
||||
"artistic.watercolor",
|
||||
&[json!({}), json!({"brushDetail": -1e308, "shadowIntensity": 1e308, "texture": 1e308}), json!({"brushDetail": "NaN"})],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(w / 2.0, h / 2.0),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * w.hypot(h),
|
||||
}
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx(id, p).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// Channel 0 of pixel (`x`, `y`).
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> u8 {
|
||||
d[(y * w + x) * 4]
|
||||
}
|
||||
|
||||
/// How many pixels of channel 0 are darker than 128.
|
||||
fn black(d: &[u8]) -> usize {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
|
||||
}
|
||||
|
||||
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
|
||||
fn survives(id: &str, params: &[serde_json::Value]) {
|
||||
let (w, h) = (9, 7);
|
||||
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
|
||||
for p in params {
|
||||
for s in [tiny, huge, nan, space(w, h)] {
|
||||
let mut out = src.clone();
|
||||
fx(id, p.clone()).apply(&mut out, w, h, &s);
|
||||
assert_eq!(out.len(), src.len());
|
||||
let alphas = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| p[3]).collect::<Vec<_>>();
|
||||
if s.px == 1.0 {
|
||||
assert_eq!(alphas(&out), alphas(&src), "{id}: coverage kept");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A size that doesn't match, and an empty raster, do nothing.
|
||||
let mut odd = vec![1u8; 10];
|
||||
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
|
||||
assert_eq!(odd, vec![1u8; 10]);
|
||||
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photocopy_copies_the_edges_of_dark_areas() {
|
||||
assert_eq!(fx("sketch.photocopy", json!({})), PixelFx::Photocopy { detail: 7.0, darkness: 25.0 });
|
||||
assert_eq!(fx("sketch.photocopy", json!({"detail": 1e308, "darkness": -5})), PixelFx::Photocopy { detail: 24.0, darkness: 1.0 });
|
||||
let (w, h) = (64, 16);
|
||||
let src = image(w, h, |x, _| grey(if x < 32 { 30 } else { 230 }));
|
||||
let out = run("sketch.photocopy", json!({"detail": 20}), &src, w, h);
|
||||
// Black just on the dark side of the edge; white in the dark area's middle and in the light.
|
||||
assert!(at(&out, w, 30, 8) < 64, "edge {}", at(&out, w, 30, 8));
|
||||
assert!(at(&out, w, 6, 8) > 192, "dark area {}", at(&out, w, 6, 8));
|
||||
assert!(at(&out, w, 50, 8) > 192, "light area {}", at(&out, w, 50, 8));
|
||||
assert_eq!(out, run("sketch.photocopy", json!({"detail": 20}), &src, w, h), "deterministic");
|
||||
// A higher Darkness copies more.
|
||||
let more = run("sketch.photocopy", json!({"detail": 20, "darkness": 50}), &src, w, h);
|
||||
let less = run("sketch.photocopy", json!({"detail": 20, "darkness": 1}), &src, w, h);
|
||||
assert!(black(&more) >= black(&less));
|
||||
survives("sketch.photocopy", &[json!({}), json!({"detail": 1e308, "darkness": "NaN"}), json!({"detail": -1e308, "darkness": [1]})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_prints_solid_black_and_white() {
|
||||
assert_eq!(fx("sketch.stamp", json!({})), PixelFx::Stamp { balance: 25.0, smoothness: 5.0 });
|
||||
assert_eq!(fx("sketch.stamp", json!({"lightDarkBalance": 1e308, "smoothness": -1})), PixelFx::Stamp { balance: 50.0, smoothness: 1.0 });
|
||||
let (w, h) = (64, 8);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let few = run("sketch.stamp", json!({"lightDarkBalance": 0}), &src, w, h);
|
||||
let many = run("sketch.stamp", json!({"lightDarkBalance": 50}), &src, w, h);
|
||||
assert!(black(&many) > black(&few), "{} vs {}", black(&many), black(&few));
|
||||
// Pure black and white, but for the one pixel wide edges.
|
||||
let mixed = many.as_chunks::<4>().0.iter().filter(|p| p[0] != 0 && p[0] != 255).count();
|
||||
assert!(mixed * 10 < many.len() / 4, "{mixed} grey pixels");
|
||||
assert_eq!(at(&many, w, 0, 4), 0);
|
||||
assert_eq!(at(&many, w, 63, 4), 255);
|
||||
survives(
|
||||
"sketch.stamp",
|
||||
&[json!({}), json!({"lightDarkBalance": "NaN", "smoothness": 1e308}), json!({"lightDarkBalance": -1e308, "smoothness": null})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charcoal_draws_bold_edges_and_sketches_the_midtones() {
|
||||
assert_eq!(fx("sketch.charcoal", json!({})), PixelFx::Charcoal { thickness: 1.0, detail: 1.0, balance: 50.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.charcoal", json!({"thickness": 1e308, "detail": -5, "lightDarkBalance": "x"})),
|
||||
PixelFx::Charcoal { thickness: 7.0, detail: 0.0, balance: 50.0 }
|
||||
);
|
||||
let (w, h) = (64, 48);
|
||||
// Black on the left, white on the right: a bold line at the edge, nothing in the white.
|
||||
let step = image(w, h, |x, _| grey(if x < 32 { 0 } else { 255 }));
|
||||
let out = run("sketch.charcoal", json!({}), &step, w, h);
|
||||
assert!((28..36).any(|x| at(&out, w, x, 24) < 64), "edge");
|
||||
assert!((48..64).all(|x| at(&out, w, x, 24) == 255), "white stays white");
|
||||
// A midtone is sketched in strokes, more of it the more the balance reaches into the lights.
|
||||
let mid = image(w, h, |_, _| grey(128));
|
||||
let sketched = run("sketch.charcoal", json!({}), &mid, w, h);
|
||||
let (n, total) = (black(&sketched), w * h);
|
||||
assert!(n > total / 50 && n < total * 9 / 10, "{n} of {total}");
|
||||
let darker = run("sketch.charcoal", json!({"lightDarkBalance": 100}), &mid, w, h);
|
||||
let lighter = run("sketch.charcoal", json!({"lightDarkBalance": 0}), &mid, w, h);
|
||||
assert!(black(&darker) > black(&lighter));
|
||||
assert_eq!(sketched, run("sketch.charcoal", json!({}), &mid, w, h), "deterministic");
|
||||
let white = image(w, h, |_, _| grey(255));
|
||||
assert_eq!(black(&run("sketch.charcoal", json!({"lightDarkBalance": 0}), &white, w, h)), 0);
|
||||
survives(
|
||||
"sketch.charcoal",
|
||||
&[json!({}), json!({"thickness": 1e308, "detail": "NaN", "lightDarkBalance": [1]}), json!({"thickness": -1e308, "detail": 1e308})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bas_relief_lights_the_surfaces_facing_the_light() {
|
||||
assert_eq!(fx("sketch.basRelief", json!({})), PixelFx::BasRelief { detail: 13.0, smoothness: 3.0, light: Light::Bottom });
|
||||
assert_eq!(
|
||||
fx("sketch.basRelief", json!({"detail": 0, "smoothness": 1e308, "lightDirection": "Top"})),
|
||||
PixelFx::BasRelief { detail: 1.0, smoothness: 15.0, light: Light::Top }
|
||||
);
|
||||
// Dark above, light below: the edge is lit from one side and shaded from the other.
|
||||
let (w, h) = (16, 48);
|
||||
let src = image(w, h, |_, y| grey(if y < 24 { 20 } else { 235 }));
|
||||
let below = run("sketch.basRelief", json!({"lightDirection": "bottom"}), &src, w, h);
|
||||
let above = run("sketch.basRelief", json!({"lightDirection": "top"}), &src, w, h);
|
||||
let edge = (0..h).map(|y| (y, i32::from(at(&below, w, 8, y)) - i32::from(at(&above, w, 8, y)))).max_by_key(|(_, d)| d.abs());
|
||||
assert!(edge.is_some_and(|(_, d)| d.abs() > 100), "{edge:?}");
|
||||
// Flat areas are grey.
|
||||
for y in [2, 45] {
|
||||
let v = at(&below, w, 8, y);
|
||||
assert!((90..190).contains(&v), "flat {v}");
|
||||
}
|
||||
survives(
|
||||
"sketch.basRelief",
|
||||
&[json!({}), json!({"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}), json!({"detail": -1e308, "smoothness": null})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
|
||||
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
|
||||
let f = fx(id, json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
|
||||
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +146,7 @@ impl Light {
|
||||
}
|
||||
|
||||
/// The unit vector towards the light (document axes, y down).
|
||||
fn toward(self) -> Vec2 {
|
||||
pub(super) fn toward(self) -> Vec2 {
|
||||
let d = FRAC_1_SQRT_2;
|
||||
match self {
|
||||
Light::Bottom => Vec2::new(0.0, 1.0),
|
||||
@@ -269,7 +287,7 @@ impl Tones {
|
||||
}
|
||||
|
||||
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
|
||||
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
|
||||
pub(super) fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
|
||||
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
|
||||
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
|
||||
// Per pixel → per document unit: through the transpose of the document → pixel map.
|
||||
@@ -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))
|
||||
}
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
|
||||
+107
-19
@@ -7,12 +7,14 @@
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
|
||||
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
|
||||
//! them ([`image_rows`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -44,11 +46,75 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
|
||||
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
|
||||
/// Outline Object)?
|
||||
fn gives_shape(e: &Effect) -> bool {
|
||||
e.visible
|
||||
&& matches!(e.id.as_str(), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" | "path.outlineObject")
|
||||
}
|
||||
|
||||
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
|
||||
/// that gives it a shape ([`gives_shape`])?
|
||||
fn shapes_image(item: &AppearanceItem) -> bool {
|
||||
crate::paints(item) && item.effects().iter().any(gives_shape)
|
||||
}
|
||||
|
||||
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
|
||||
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
|
||||
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
|
||||
/// nothing.
|
||||
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|i| shapes_image(i))
|
||||
.map(|i| {
|
||||
let mut i = i.clone();
|
||||
let fx = i.effects_mut();
|
||||
let first = fx.iter().position(gives_shape).unwrap_or(0);
|
||||
fx.drain(..first);
|
||||
i
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
|
||||
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
|
||||
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
|
||||
/// them. Empty for anything but an image with such fills or strokes.
|
||||
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(art) = reshape(n, None) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
|
||||
let Some(g) = c.stroke_path() else { return };
|
||||
let ctx = GeomContext::of(c);
|
||||
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
let fx = item.effects();
|
||||
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
|
||||
crate::apply_geometry_bez(fx, &g, g.bounding_box(), &ctx.item(item))
|
||||
} else {
|
||||
g.clone()
|
||||
};
|
||||
out.push((shape, *rule, item.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
@@ -186,11 +252,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
|
||||
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
|
||||
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
|
||||
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
@@ -200,14 +268,17 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
_ => has_item_geometry(n),
|
||||
};
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
@@ -220,12 +291,28 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
let mut clip = Node::path(n.id, frame, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, n.kind.clone());
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
|
||||
let clipped = || {
|
||||
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
|
||||
};
|
||||
// The fills and strokes that paint a shape, as paths along the frame below and above
|
||||
// the image.
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let (below, above) = (image_rows(below), image_rows(above));
|
||||
if below.is_empty() && above.is_empty() {
|
||||
clipped()
|
||||
} else {
|
||||
let rows = |items: Vec<AppearanceItem>| {
|
||||
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
|
||||
};
|
||||
// Without geometry effects, the image is not clipped.
|
||||
let image = if fx.is_empty() { image.clone() } else { clipped() };
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
@@ -234,7 +321,8 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if let Some(h) = n.projection() {
|
||||
project_leaves(&mut art, &h);
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
|
||||
// group).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
|
||||
@@ -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 {
|
||||
@@ -212,8 +219,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
||||
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// otherwise each solid is a surface of its own, in that order.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
@@ -229,12 +240,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
|
||||
.map(|(_, i)| {
|
||||
let (area, rule) = region(path, rule, i);
|
||||
(*i, area, rule)
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, _, _), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
// instead of failing the frame; commands validate the same evaluator and report its
|
||||
// errors.
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, _)| {
|
||||
let (_, opacity, blend) = paint_of(item);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
.into_iter()
|
||||
.map(|(item, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
(depths, surface(n, f, &paint, opacity, blend, colored))
|
||||
})
|
||||
.collect();
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
for (depths, s) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
}
|
||||
}
|
||||
// A stable sort: faces at the same depth keep the order of the appearance.
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
|
||||
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_stroke_region_of_a_round_line_stays_compact() {
|
||||
use std::f64::consts::PI;
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, SubPath};
|
||||
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
|
||||
let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round);
|
||||
let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st);
|
||||
let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
|
||||
assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}");
|
||||
let area = region.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
|
||||
assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}");
|
||||
// Extra colinear anchors along the spine must not multiply outline anchors (#3446).
|
||||
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
|
||||
let dense_path = PathData::single(SubPath::polyline(&dense, false));
|
||||
let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st);
|
||||
let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
|
||||
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
let l = line(0.0, 100.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
|
||||
// hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
|
||||
|
||||
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
|
||||
fn stroked(b: &BezPath) -> Node {
|
||||
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
|
||||
}
|
||||
|
||||
/// A `width` × 100 pt rectangle at (100, 100), stroked.
|
||||
fn stroked_rect(width: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((100.0 + width, 100.0));
|
||||
b.line_to((100.0 + width, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
stroked(&b)
|
||||
}
|
||||
|
||||
/// Is `face` in the stroke's blue (rather than the fill's red)?
|
||||
fn blue(face: &Node) -> bool {
|
||||
let [r, _, b] = fill_rgb(face);
|
||||
b > r
|
||||
}
|
||||
|
||||
/// The bounds of the faces' own paths (without their seam strokes).
|
||||
fn extent(faces: &[&Node]) -> Rect {
|
||||
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
|
||||
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
|
||||
}
|
||||
|
||||
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
|
||||
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
|
||||
/// fill's, and they are drawn in the stroke's paint.
|
||||
#[test]
|
||||
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
|
||||
assert!(!strokes.is_empty(), "{id}: the stroke draws");
|
||||
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
|
||||
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
|
||||
// Its own blue, shaded: never brighter, never another hue.
|
||||
for f in &strokes {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
|
||||
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
|
||||
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
|
||||
/// drawn below the fill's.
|
||||
#[test]
|
||||
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
|
||||
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
|
||||
// caps; the mean of its outline's points is not the mean of the stroke's.
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((400.0, 100.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
|
||||
let mut n = stroked(&b);
|
||||
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
|
||||
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let flat = params["depth"] == json!(0);
|
||||
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
|
||||
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
|
||||
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
|
||||
// The stroke's cap is a ring: its outline and the hole the fill shows through.
|
||||
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
|
||||
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
|
||||
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
|
||||
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
|
||||
if !flat {
|
||||
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
|
||||
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
|
||||
#[test]
|
||||
fn a_stroked_open_path_builds_a_ribbon() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
assert!(faces.iter().all(|f| blue(f)), "{id}");
|
||||
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
|
||||
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
|
||||
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
|
||||
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
|
||||
/// still draws.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out() {
|
||||
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..1500)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
|
||||
Point::new(200.0 + r * c, 200.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
|
||||
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
|
||||
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
|
||||
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
|
||||
#[test]
|
||||
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
|
||||
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
|
||||
// 1 pt stroke, so the stroke stays flat.
|
||||
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
|
||||
let mut n = stroked_rect(length);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.width = width;
|
||||
}
|
||||
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
|
||||
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
|
||||
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
|
||||
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
|
||||
#[test]
|
||||
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
|
||||
assert!(!blue(fill_back) && blue(stroke_back));
|
||||
let fill = fill_back.path_data().unwrap().bounds().unwrap();
|
||||
// The ring's inner contour: the smaller of its two.
|
||||
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
|
||||
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
|
||||
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
|
||||
}
|
||||
|
||||
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
|
||||
/// opacity applies to the stroke as a whole.
|
||||
#[test]
|
||||
fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.opacity = opacity;
|
||||
s.blend = blend;
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
|
||||
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
@@ -230,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
assert_eq!(clips, 1);
|
||||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||||
// The fill's solid and the stroke's, each one composite with its own clip.
|
||||
let surfaces = art.children().unwrap().len();
|
||||
assert_eq!(surfaces, 2);
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
}
|
||||
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
|
||||
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
|
||||
//! paint below the members and which above ([`Appearance::contents_at`], moved by
|
||||
//! `appearance.moveItem {from: "contents"}`).
|
||||
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
@@ -35,8 +37,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"appearance.addFill",
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
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)
|
||||
),
|
||||
@@ -44,8 +46,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"appearance.addStroke",
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
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)
|
||||
),
|
||||
@@ -117,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -216,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),
|
||||
@@ -381,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> {
|
||||
@@ -408,6 +411,9 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
|
||||
let defaults = s.paint.clone();
|
||||
// Where the new item went in on the active item's object, when it is an image.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut inserted = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -429,11 +435,23 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
} else {
|
||||
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
|
||||
};
|
||||
n.appearance.items.push(item);
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
// On an image it goes right below the Contents row (the image).
|
||||
let k = n.appearance.contents_at();
|
||||
n.appearance.items.insert(k, item);
|
||||
n.appearance.set_contents_at(k + 1);
|
||||
if active == Some(*id) {
|
||||
inserted = Some(k);
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The rows above the new item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -478,7 +496,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
|
||||
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
|
||||
if to.contents_label().is_none() {
|
||||
ap.contents_index = None;
|
||||
}
|
||||
@@ -530,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() {
|
||||
@@ -586,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)?],
|
||||
@@ -717,7 +736,7 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
n.appearance.set_contents_at(to);
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
@@ -739,11 +758,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
@@ -757,12 +778,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
targets.retain(|id| *id != src_id);
|
||||
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
|
||||
let range = range.filter(|(id, _, _)| targets.contains(id));
|
||||
// Character styles cannot represent object transparency. Refuse before even changing
|
||||
// paint defaults; applying it to the object would also alter the unselected characters.
|
||||
if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) {
|
||||
let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
|
||||
return Err(bad(
|
||||
C,
|
||||
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
|
||||
));
|
||||
}
|
||||
}
|
||||
if !reverse {
|
||||
picked.to_defaults(s, &attrs.appearance);
|
||||
}
|
||||
if targets.is_empty() || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
if let Some((id, start, end)) = range {
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let placed = picked.placed(Some(t.local_bounds()));
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
|
||||
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
|
||||
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(count_changed(d, &before, &[id]))
|
||||
})?;
|
||||
return Ok(json!({ "ids": [id.0] }));
|
||||
}
|
||||
// When every target comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
@@ -824,6 +871,22 @@ impl Picked {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy character attributes and the enabled paint attributes through the same path for
|
||||
/// whole text objects and highlighted character ranges.
|
||||
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
|
||||
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
|
||||
*st = CharStyle {
|
||||
fill: std::mem::take(&mut st.fill),
|
||||
stroke: std::mem::take(&mut st.stroke),
|
||||
stroke_width: st.stroke_width,
|
||||
overprint_fill: st.overprint_fill,
|
||||
overprint_stroke: st.overprint_stroke,
|
||||
..cs.clone()
|
||||
};
|
||||
}
|
||||
run_attrs(st, placed, &a.appearance);
|
||||
}
|
||||
|
||||
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
|
||||
/// stack instead. Type takes the paints on its characters, and character and paragraph
|
||||
/// attributes from type.
|
||||
@@ -843,19 +906,7 @@ impl Picked {
|
||||
let placed = self.placed(Some(t.local_bounds()));
|
||||
let text = self.text.as_ref();
|
||||
for r in &mut t.runs {
|
||||
if let Some((cs, _)) = text.filter(|_| a.character) {
|
||||
// The character attributes; the paints follow the appearance attributes.
|
||||
let st = &mut r.style;
|
||||
*st = CharStyle {
|
||||
fill: std::mem::take(&mut st.fill),
|
||||
stroke: std::mem::take(&mut st.stroke),
|
||||
stroke_width: st.stroke_width,
|
||||
overprint_fill: st.overprint_fill,
|
||||
overprint_stroke: st.overprint_stroke,
|
||||
..cs.clone()
|
||||
};
|
||||
}
|
||||
run_attrs(&mut r.style, &placed, ap);
|
||||
self.run(&mut r.style, &placed, a);
|
||||
}
|
||||
if let Some((_, para)) = text.filter(|_| a.paragraph) {
|
||||
// The source's first paragraph's attributes, on every paragraph.
|
||||
|
||||
@@ -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. 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
|
||||
),
|
||||
@@ -210,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbolism Tool",
|
||||
[],
|
||||
None,
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
has_doc,
|
||||
symbol_adjust
|
||||
),
|
||||
@@ -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,13 +1301,14 @@ 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;
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let st = s.doc()?;
|
||||
let color = match p.get("color").and_then(color_value) {
|
||||
Some(c) => c,
|
||||
Some(c) => super::paint::new_color(s, p, c),
|
||||
None => s.paint.fill.color().unwrap_or(Color::BLACK),
|
||||
};
|
||||
let style_paint = match str_param(p, "style") {
|
||||
@@ -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), gap?: pt (0–72; 0: Gap Detection off), 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?, gap?: pt (0–72; 0: Gap Detection off), 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, skipping hidden faces → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -232,10 +232,16 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
|
||||
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
|
||||
paint_from(s, v)
|
||||
}
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gap Detection length in points, bounded like Live Paint's custom gap size.
|
||||
fn gap_param(p: &Value) -> f64 {
|
||||
let gap = f64_or(p, "gap", 0.0);
|
||||
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shapeBuilder.merge";
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
|
||||
@@ -245,12 +251,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::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
|
||||
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()));
|
||||
@@ -268,7 +286,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they follow.
|
||||
let cut_tol = step * 0.2;
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let faces = po::FaceMerger::new(&map.shapes);
|
||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
// area only lines enclose) of the front-most object and the current fill.
|
||||
@@ -311,7 +329,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 +384,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::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
@@ -501,8 +519,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 +545,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 +691,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 +758,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![] };
|
||||
@@ -734,13 +770,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
g
|
||||
}
|
||||
None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) {
|
||||
Some(g) => g,
|
||||
None => make_live(d, sel, &roots)?.0,
|
||||
},
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => g,
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => make_live(d, sel, &roots)?.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
let fid =
|
||||
d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
|
||||
let fid = d
|
||||
.node(gid)
|
||||
.and_then(|n| b::visible_face_at(n, pt))
|
||||
.map(|f| f.id)
|
||||
.ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
|
||||
d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone());
|
||||
Ok((gid, fid))
|
||||
})?;
|
||||
@@ -752,6 +799,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 +808,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 +929,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.)
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -30,6 +30,10 @@ pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// A `.ai` or `.ait` file whose bytes are a PDF, copied in a file manager ([`file_flavour`]): Paste
|
||||
/// reads it as `document.open` does, from its editing data. The type is VectorCraft's own: no
|
||||
/// system clipboard carries it, and Paste asks for [`PDF`] to get it.
|
||||
pub const AI: &str = "application/x-ai";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
@@ -71,8 +75,9 @@ pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
|
||||
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file is [`AI`] when its bytes are a PDF (it
|
||||
/// pastes when PDF is asked for) and EPS when they are PostScript. The file's bytes are that
|
||||
/// flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
@@ -83,7 +88,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, AI),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
@@ -122,6 +127,13 @@ pub struct Flavour {
|
||||
const COPY_PNG_SIDE: f64 = 4096.0;
|
||||
/// Most bytes of text `clipboard.importText` takes.
|
||||
const MAX_TEXT: usize = 1 << 20;
|
||||
/// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the
|
||||
/// clipboard's text first (eframe reads it on every paste key; on Linux and macOS it is the one
|
||||
/// format the app writes, and checking that the clipboard still holds our copy reads it again), so
|
||||
/// a placed photo in it made each paste move megabytes of base64 through the system clipboard on
|
||||
/// the UI thread, and on a GNOME desktop the app stopped responding (#2213). A copy with more image
|
||||
/// data than this offers its SVG under its own type and its PNG, not as text.
|
||||
const COPY_TEXT_IMAGE_BYTES: usize = 1 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -157,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
|
||||
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg when the images it embeds total at most 1 MiB), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
|
||||
always,
|
||||
flavours
|
||||
),
|
||||
@@ -184,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (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
|
||||
),
|
||||
@@ -202,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use; a hidden layer's objects hidden), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
@@ -263,12 +275,17 @@ impl Clipboard {
|
||||
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// Bytes of the encoded images the objects show (what their SVG embeds as base64).
|
||||
fn image_bytes(&self) -> usize {
|
||||
self.images.values().fold(0usize, |sum, b| sum.saturating_add(b.bytes.len()))
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
|
||||
if self.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let text = svg || self.nodes.iter().all(type_only);
|
||||
let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only);
|
||||
// Art without an area (a lone guide-like line) has no page or picture.
|
||||
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
|
||||
@@ -424,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)?;
|
||||
@@ -452,8 +470,19 @@ fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
import_loaded(s, p, cmd, loaded)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with every object of `loaded`, a file read as `document.open` reads it
|
||||
/// (command `cmd`). The layers are left out, and the objects of a hidden layer come in hidden.
|
||||
fn import_loaded(s: &mut Session, p: &Value, cmd: &str, mut loaded: fileio::Loaded) -> Result<Value> {
|
||||
loaded.doc.drop_edit_modes();
|
||||
for layer in loaded.doc.layers.iter_mut().filter(|l| !l.visible) {
|
||||
for object in std::sync::Arc::make_mut(layer).children_mut().into_iter().flatten() {
|
||||
std::sync::Arc::make_mut(object).visible = false;
|
||||
}
|
||||
}
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
@@ -469,8 +498,28 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
|
||||
};
|
||||
let editing = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "editingData must be true or false"))?,
|
||||
};
|
||||
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
// A copied .ai file pastes every object of its editing data, as document.open reads the file.
|
||||
// When document.open would not use that copy, the file pastes its page, as a PDF does.
|
||||
if editing
|
||||
&& let Ok(loaded) = fileio::load_with("Clipboard.ai", &bytes, &opts)
|
||||
&& (loaded.editing_data || loaded.restored)
|
||||
{
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.addToSwatches";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let t = &s.prefs.color_themes[find(s, name, C)?];
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
|
||||
// The theme's colors keep their model, in a CMYK document too.
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
|
||||
let r = s.execute("swatch.newGroup", ¶ms)?;
|
||||
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -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 {
|
||||
@@ -573,8 +576,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
|
||||
/// filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
@@ -587,7 +591,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
style.font_style = f.to_string();
|
||||
}
|
||||
style.fill = match p.get("color").and_then(color_value) {
|
||||
Some(c) => vectorcraft_color::Paint::solid(c),
|
||||
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
|
||||
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
|
||||
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
};
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
+167
-42
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -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,18 @@ 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
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
@@ -284,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
|
||||
}
|
||||
|
||||
@@ -548,6 +575,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -556,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);
|
||||
@@ -630,6 +738,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
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 = 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"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -650,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"));
|
||||
}
|
||||
@@ -692,7 +814,8 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
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,
|
||||
@@ -756,7 +879,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,12 +986,14 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
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"));
|
||||
@@ -898,10 +1023,10 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
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"));
|
||||
}
|
||||
@@ -923,9 +1048,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
@@ -1018,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"));
|
||||
}
|
||||
@@ -1060,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"));
|
||||
}
|
||||
@@ -1203,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![];
|
||||
@@ -1251,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 = {
|
||||
@@ -1350,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"));
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
"edit.clearHistory",
|
||||
"Clear History",
|
||||
[],
|
||||
None,
|
||||
"{} remove every undo and redo step of the active document (the History panel menu's Clear History): the document stays as it is, and clearing is not an undo step → {cleared: the number of steps removed}",
|
||||
can_clear_history,
|
||||
clear_history
|
||||
),
|
||||
cmd!(
|
||||
"edit.undoGroup.begin",
|
||||
"Begin Undo Group",
|
||||
@@ -162,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"));
|
||||
}
|
||||
@@ -170,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")) }
|
||||
}
|
||||
|
||||
@@ -380,7 +389,9 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.redo.last());
|
||||
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.redo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -388,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 }))
|
||||
}
|
||||
@@ -403,17 +415,33 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.undo.last());
|
||||
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.undo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// History panel › Clear History: removes the steps after the current state and the steps before
|
||||
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
|
||||
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
|
||||
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let cleared = st.history.undo.len() + st.history.redo.len();
|
||||
st.history.undo.clear();
|
||||
st.history.redo.clear();
|
||||
st.revision += 1;
|
||||
Ok(json!({ "cleared": cleared }))
|
||||
}
|
||||
|
||||
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
|
||||
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
|
||||
match like {
|
||||
|
||||
@@ -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"))?;
|
||||
@@ -139,11 +152,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
super::params::check_effect_channel(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
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}`")))?;
|
||||
@@ -161,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));
|
||||
@@ -298,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)?;
|
||||
@@ -317,8 +354,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
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)? {
|
||||
@@ -336,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| {
|
||||
@@ -371,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
|
||||
@@ -406,6 +448,7 @@ fn expandable(n: &Node) -> bool {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
@@ -439,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,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).
|
||||
@@ -507,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) {
|
||||
@@ -520,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
|
||||
})
|
||||
};
|
||||
@@ -534,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> {
|
||||
@@ -572,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;
|
||||
|
||||
@@ -290,7 +290,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
let frame = Frame::new(g, bounds);
|
||||
@@ -363,15 +363,16 @@ fn reshaped(l: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
|
||||
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
|
||||
/// `path` as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
if !has_geometry(item.effects()) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
|
||||
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
@@ -432,7 +433,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
@@ -603,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))))
|
||||
@@ -720,29 +721,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
|
||||
|
||||
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
|
||||
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
|
||||
/// colour model their stops (or freeform points) share; display RGB where models differ.
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
/// color model their stops share; display RGB where models differ. Freeform points mix in the
|
||||
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
|
||||
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
|
||||
/// stores an RGB color ([`super::paint::stored_color`]).
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
let b = bounds.abs();
|
||||
let f = g.freeform_on(b);
|
||||
let model = shared_model(f.points.iter().map(|p| p.color));
|
||||
let field = f.field(spread_scale(b));
|
||||
let field = g.freeform_on(b).model_field(spread_scale(b));
|
||||
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
|
||||
return Box::new(move |p| {
|
||||
let ([r, g, b], a) = field.sample(p);
|
||||
let c = Color::rgb(r, g, b);
|
||||
(model.map_or(c, |m| c.in_model(m)), a)
|
||||
let (c, a) = field.sample(p);
|
||||
(super::paint::stored_color(store_in, c), a)
|
||||
});
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
||||
}
|
||||
|
||||
/// The colour model all of `colors` share.
|
||||
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
|
||||
let m = colors.next()?.model();
|
||||
colors.all(|c| c.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
|
||||
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -411,6 +411,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
if let Some(plugin) = vectorcraft_plugins::export::by_format(f.id) {
|
||||
return plugin_file(doc, &plugin, f, p);
|
||||
}
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
|
||||
@@ -432,6 +435,32 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// The file exporter plug-in `plugin` writes of `doc` in its format `f`
|
||||
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
|
||||
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
|
||||
/// one named after the document) with that path's extension when the plug-in lists it, else its
|
||||
/// first. Its warnings come back with the file.
|
||||
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
use vectorcraft_plugins::{Error, export};
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let file = vectorcraft_format::save_with(doc, &vectorcraft_format::SaveOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let path = p.get("path").and_then(Value::as_str);
|
||||
let listed = path.map(super::extension).and_then(|e| f.extensions.iter().copied().find(|x| *x == e));
|
||||
let extension = listed.or(f.extensions.first().copied()).unwrap_or_default();
|
||||
let name = super::file_stem(path.unwrap_or(&super::default_name(doc, extension)));
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let target = export::Target { extension, name: &name };
|
||||
let out = export::run(plugin, vectorcraft_format::json_part(&file), ¶ms, target).map_err(|e| match e {
|
||||
Error::Cancelled => EngineError::Cancelled,
|
||||
Error::Params(why) => bad(C, why),
|
||||
e => EngineError::Other(e.to_string()),
|
||||
})?;
|
||||
// The plug-in's output, so reported as its other output errors are.
|
||||
let invalid = || EngineError::Other(Error::Output("`dataBase64` is not valid base64".into()).to_string());
|
||||
let bytes = vectorcraft_format::base64_decode(&out.data_base64).ok_or_else(invalid)?;
|
||||
Ok(Encoded { warnings: out.warnings, ..Encoded::one(bytes) })
|
||||
}
|
||||
|
||||
/// [`encode_all`] without the font substitution warning.
|
||||
fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let doc = &*doc.without_edit_modes();
|
||||
|
||||
Loaded 100 of 612 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user