Compare commits
893
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c42a2942a | ||
|
|
36286cc783 | ||
|
|
0573d3cdfb | ||
|
|
9ca74ac011 | ||
|
|
f0f3cb4eb1 | ||
|
|
5fe916a425 | ||
|
|
8e7b9ba436 | ||
|
|
cbcee3bd54 | ||
|
|
0810a15f6e | ||
|
|
db7e568809 | ||
|
|
9d72686d95 | ||
|
|
d79fb145c2 | ||
|
|
6a16627de6 | ||
|
|
acc1c9a85d | ||
|
|
8fbf724cf6 | ||
|
|
02ed637c68 | ||
|
|
9d2b074d12 | ||
|
|
632ea2a9f3 | ||
|
|
523fd6e185 | ||
|
|
612082e26e | ||
|
|
fea316911d | ||
|
|
3af08505f8 | ||
|
|
2c01a42b35 | ||
|
|
45334df881 | ||
|
|
ee97225c19 | ||
|
|
6085197bf4 | ||
|
|
50911d8608 | ||
|
|
6939eff2f9 | ||
|
|
9652d1d6b2 | ||
|
|
79b5d73ca7 | ||
|
|
05fdd15b45 | ||
|
|
0a16b62a22 | ||
|
|
20f5174710 | ||
|
|
00195e8d28 | ||
|
|
05e321f4e0 | ||
|
|
85770d3afb | ||
|
|
421443b8ac | ||
|
|
e2a63a8baf | ||
|
|
44d170f059 | ||
|
|
fce90e3144 | ||
|
|
761cc2371a | ||
|
|
3752b3ec31 | ||
|
|
c2ed31dc4c | ||
|
|
d9e44350d2 | ||
|
|
0f4de5b98e | ||
|
|
a971b5b70e | ||
|
|
6beaa89be2 | ||
|
|
40b02f479b | ||
|
|
afda1cd846 | ||
|
|
a7c6644691 | ||
|
|
3a88ad0855 | ||
|
|
61cc3f6040 | ||
|
|
3239e8b97e | ||
|
|
48d5bd1aee | ||
|
|
5203f4f047 | ||
|
|
9dfa7bf931 | ||
|
|
a072e29016 | ||
|
|
ba68fcfbfc | ||
|
|
02a68e1738 | ||
|
|
c9fac95760 | ||
|
|
b631002c58 | ||
|
|
041776fc9f | ||
|
|
78155ed5b8 | ||
|
|
51c18f603a | ||
|
|
66add30d65 | ||
|
|
b3f6edddfe | ||
|
|
a046fd30fe | ||
|
|
1e5f430ac5 | ||
|
|
f3040e3762 | ||
|
|
db54eefeae | ||
|
|
37954523ae | ||
|
|
c391e1dbf2 | ||
|
|
20ec49b2e7 | ||
|
|
8715130c8b | ||
|
|
6689be0351 | ||
|
|
c2c8a07b75 | ||
|
|
b83eafd794 | ||
|
|
e93fc20294 | ||
|
|
9e2a7c41a0 | ||
|
|
a4ba47a439 | ||
|
|
93e8c00607 | ||
|
|
1e0063258a | ||
|
|
15cef94ba6 | ||
|
|
431dc43e43 | ||
|
|
e750891912 | ||
|
|
fb2089aaf5 | ||
|
|
88bd4812e0 | ||
|
|
6f9e2d5c16 | ||
|
|
d084af162f | ||
|
|
e01b9a20d9 | ||
|
|
bd9b8a2db3 | ||
|
|
c67c25128c | ||
|
|
b9e5536794 | ||
|
|
15e1f6327f | ||
|
|
57a4d9c6e1 | ||
|
|
e87abf02a8 | ||
|
|
e4c2b3db94 | ||
|
|
2ec817e76a | ||
|
|
9e39a6d124 | ||
|
|
ec8dc0c2da | ||
|
|
22e06db73d | ||
|
|
aebeb66268 | ||
|
|
cf137c8c90 | ||
|
|
e131dfbb1c | ||
|
|
77f81c8944 | ||
|
|
7fadeb4ed2 | ||
|
|
396d7d2b76 | ||
|
|
e30b1492a1 | ||
|
|
e6ae1d0df3 | ||
|
|
43e1a7380c | ||
|
|
7ab9a46bf8 | ||
|
|
dd1c26b627 | ||
|
|
a787fb487c | ||
|
|
ce0c86f91a | ||
|
|
4204ce36db | ||
|
|
a891530ef2 | ||
|
|
c372585b73 | ||
|
|
8b8fd1476b | ||
|
|
214312f883 | ||
|
|
2c74cd928a | ||
|
|
c1be8b82db | ||
|
|
c3c036daf2 | ||
|
|
905a965d1e | ||
|
|
b3ada24da7 | ||
|
|
1eb9b34439 | ||
|
|
b49484af08 | ||
|
|
908b4ddcbc | ||
|
|
fcc52c3c7b | ||
|
|
499cd4f30c | ||
|
|
3672e0ae84 | ||
|
|
3acc29f8e0 | ||
|
|
ff6e136b97 | ||
|
|
897d99a70f | ||
|
|
7625feea08 | ||
|
|
e74cb01ecc | ||
|
|
67af0cdf82 | ||
|
|
3dbb551db3 | ||
|
|
09f62097f0 | ||
|
|
81a568f4c6 | ||
|
|
cb9d93b647 | ||
|
|
974325b62a | ||
|
|
3a8e1ec0f8 | ||
|
|
05667c40b1 | ||
|
|
571289ddb6 | ||
|
|
08c8c11798 | ||
|
|
847730bf8b | ||
|
|
0d8b08ccd4 | ||
|
|
fcd7e3b42d | ||
|
|
a17e3057de | ||
|
|
82ac5c92ea | ||
|
|
7718b62735 | ||
|
|
63e0bff91d | ||
|
|
e2177c0d3a | ||
|
|
2cf2b972f5 | ||
|
|
8a3458a974 | ||
|
|
f4e72fe6a5 | ||
|
|
8754646784 | ||
|
|
2b34554ac8 | ||
|
|
9f0a91b638 | ||
|
|
f2c7c17eb1 | ||
|
|
182a2cda4b | ||
|
|
80af380a6a | ||
|
|
9251edf1dc | ||
|
|
988f52ecc8 | ||
|
|
ffa948dcc3 | ||
|
|
25c33b6927 | ||
|
|
baf9693f0f | ||
|
|
e01a0a70be | ||
|
|
911c65de34 | ||
|
|
83d271fb89 | ||
|
|
8811cc552e | ||
|
|
9e4ed2d430 | ||
|
|
42c923d62a | ||
|
|
939bc02f6b | ||
|
|
77120ada62 | ||
|
|
92f117cf1d | ||
|
|
5b413448d9 | ||
|
|
34b3c59a03 | ||
|
|
04bc43c802 | ||
|
|
57e7cd3b0e | ||
|
|
487cc29b57 | ||
|
|
4d17702536 | ||
|
|
a41d59604b | ||
|
|
003234a056 | ||
|
|
c70bd33de0 | ||
|
|
3da2685065 | ||
|
|
2c508ea867 | ||
|
|
54afb67b4f | ||
|
|
22f14bec24 | ||
|
|
1168c319b0 | ||
|
|
c26ff9a22a | ||
|
|
aed5d380e9 | ||
|
|
b4c1cdbcea | ||
|
|
2a0a279c93 | ||
|
|
ec38b00a22 | ||
|
|
d6bace14c3 | ||
|
|
a366e8ee0d | ||
|
|
e72a010e1c | ||
|
|
92ed381625 | ||
|
|
772f056f9c | ||
|
|
7f5d8a3db1 | ||
|
|
bfb173e967 | ||
|
|
f908545204 | ||
|
|
bf2b5643dc | ||
|
|
5e4fc4e3af | ||
|
|
1b79120568 | ||
|
|
106925c74b | ||
|
|
e22a11eda8 | ||
|
|
689ad46999 | ||
|
|
2ff64993eb | ||
|
|
ddebe5e479 | ||
|
|
dc640f3d61 | ||
|
|
ce55aaf2fe | ||
|
|
718751b658 | ||
|
|
28e694623d | ||
|
|
3073f0ab03 | ||
|
|
412e83c825 | ||
|
|
a9a7a4cddf | ||
|
|
de777a1b09 | ||
|
|
4744c2f04b | ||
|
|
155d921fe7 | ||
|
|
0cf59a6cbd | ||
|
|
46131bd091 | ||
|
|
b185fbbe45 | ||
|
|
8e16c8529f | ||
|
|
8ce63aa1d1 | ||
|
|
885a6e38cd | ||
|
|
ab50796a27 | ||
|
|
0765bc9bcd | ||
|
|
39c14abd35 | ||
|
|
0a357c68a9 | ||
|
|
57598c067d | ||
|
|
fc0f732677 | ||
|
|
ef0c37349d | ||
|
|
71174477cc | ||
|
|
9d63926291 | ||
|
|
2cf0452beb | ||
|
|
4251e337c1 | ||
|
|
8b96db8f0b | ||
|
|
8716fc8fbc | ||
|
|
268a4452f9 | ||
|
|
f97b24b388 | ||
|
|
a747daa346 | ||
|
|
2d619baaba | ||
|
|
f45ffdeb96 | ||
|
|
d6f1c9862d | ||
|
|
5a8682e45d | ||
|
|
52e9b25371 | ||
|
|
5d5e7a1dd2 | ||
|
|
42b355c21c | ||
|
|
a48ed5e253 | ||
|
|
aca313f10a | ||
|
|
62bf8c862c | ||
|
|
1dc61af4d1 | ||
|
|
3c3c249bc0 | ||
|
|
1909827299 | ||
|
|
cbea134e9c | ||
|
|
8115d58c3f | ||
|
|
617b78b35f | ||
|
|
5a09dfec81 | ||
|
|
6944497076 | ||
|
|
82db346d52 | ||
|
|
84336342f3 | ||
|
|
23ec8d9e67 | ||
|
|
29ecab8c48 | ||
|
|
932c127323 | ||
|
|
44685d8798 | ||
|
|
c85570c8b2 | ||
|
|
18e538ec94 | ||
|
|
6ab08dd407 | ||
|
|
0d244440a9 | ||
|
|
55e0459e21 | ||
|
|
9c41d78787 | ||
|
|
bdbcf8e1c5 | ||
|
|
7046c52520 | ||
|
|
8a7bf7920b | ||
|
|
43c988f099 | ||
|
|
4f280f0e1a | ||
|
|
b29e271b4a | ||
|
|
ae061eacfb | ||
|
|
497172bc97 | ||
|
|
307a5a6298 | ||
|
|
1e76e389d2 | ||
|
|
a99b1200ff | ||
|
|
88e9381de6 | ||
|
|
3428a224bb | ||
|
|
563ff62aa0 | ||
|
|
21c580a934 | ||
|
|
976d1bb428 | ||
|
|
96a783e716 | ||
|
|
8cb5fa0607 | ||
|
|
e74c2f113c | ||
|
|
5432821480 | ||
|
|
934d83405c | ||
|
|
fecea5390d | ||
|
|
0a538ef40d | ||
|
|
b586af83c2 | ||
|
|
be9d05b274 | ||
|
|
04a305367e | ||
|
|
d63c57d1e1 | ||
|
|
6fcdb421d1 | ||
|
|
e2f0aed1bf | ||
|
|
ecf361c9d7 | ||
|
|
ecb4be19ba | ||
|
|
023c8e3edd | ||
|
|
5beac4f122 | ||
|
|
c4214be9d7 | ||
|
|
454f1737a3 | ||
|
|
9139883075 | ||
|
|
bbbafdef6b | ||
|
|
fe8a6983c5 | ||
|
|
c373fc233c | ||
|
|
76ca890bb4 | ||
|
|
022237ce02 | ||
|
|
8b6bfe6ed7 | ||
|
|
f5f9408aa9 | ||
|
|
17cff377a8 | ||
|
|
3c5d097458 | ||
|
|
ee8bd41ce0 | ||
|
|
9d14654260 | ||
|
|
62ebd15cba | ||
|
|
ae27cf6c2b | ||
|
|
9cbab1383b | ||
|
|
d0bb09c16f | ||
|
|
4a27d897ed | ||
|
|
2c5262c5dd | ||
|
|
4412a4ea97 | ||
|
|
31ae1eba0b | ||
|
|
2b7d5f964f | ||
|
|
d3be78000e | ||
|
|
b94c13bfbb | ||
|
|
15de47b850 | ||
|
|
62e6603c30 | ||
|
|
851edd530f | ||
|
|
9792ecd6e8 | ||
|
|
f0e1de92be | ||
|
|
a24269eb15 | ||
|
|
256cd1f85a | ||
|
|
c28a9b5dd2 | ||
|
|
ae5fa87d9d | ||
|
|
e1fe2e388b | ||
|
|
6aa2b9b0fb | ||
|
|
9d8d6062e7 | ||
|
|
db8b9bb04c | ||
|
|
c117bc6e8a | ||
|
|
f8f40e7902 | ||
|
|
a40454a4a0 | ||
|
|
48c52787a1 | ||
|
|
b9716366bf | ||
|
|
70e35807e7 | ||
|
|
e3dec2e87b | ||
|
|
25c60fd0f5 | ||
|
|
714f127100 | ||
|
|
490989a9f7 | ||
|
|
981f66441e | ||
|
|
d827dc92da | ||
|
|
f602dc3d0b | ||
|
|
0586ee863d | ||
|
|
5bcd642b76 | ||
|
|
7f4f2600e2 | ||
|
|
b3f832cbcd | ||
|
|
b865547bc0 | ||
|
|
fd2ab03cd9 | ||
|
|
431e282c0d | ||
|
|
ac8d7ebce8 | ||
|
|
f714a3f9ff | ||
|
|
9ba45990ec | ||
|
|
60346d98b7 | ||
|
|
f0d2727843 | ||
|
|
86cc73be34 | ||
|
|
68b5011d18 | ||
|
|
e30147d15c | ||
|
|
ea66635af5 | ||
|
|
8b839dd50f | ||
|
|
ae3ee3ed0b | ||
|
|
5734888cf3 | ||
|
|
fade678fb9 | ||
|
|
7c62536e37 | ||
|
|
919abd4928 | ||
|
|
cd7895f717 | ||
|
|
744ff99f34 | ||
|
|
b3be342360 | ||
|
|
5cbb7989f9 | ||
|
|
ba4a870750 | ||
|
|
f683f0ff29 | ||
|
|
810af8d001 | ||
|
|
1475a0798d | ||
|
|
d0569b7fe7 | ||
|
|
876f8e283c | ||
|
|
68c3cd56f7 | ||
|
|
ce38eef890 | ||
|
|
4109717a9a | ||
|
|
978aa97396 | ||
|
|
2dc37d424d | ||
|
|
4149a09d61 | ||
|
|
1ac797685f | ||
|
|
aa15cb52bd | ||
|
|
8362223932 | ||
|
|
2f64b5a86b | ||
|
|
540505fcad | ||
|
|
86d45dc6bd | ||
|
|
611c792e59 | ||
|
|
367fca5c5e | ||
|
|
838917f7cd | ||
|
|
17eee66c19 | ||
|
|
ec51bb3251 | ||
|
|
06811241b6 | ||
|
|
53bc9d4a85 | ||
|
|
1fdd7d2146 | ||
|
|
e38e48a03b | ||
|
|
75732b3be8 | ||
|
|
0cdf51b720 | ||
|
|
38061b9515 | ||
|
|
b8aa326930 | ||
|
|
c71f57941e | ||
|
|
4268b8edd5 | ||
|
|
44f3141c3d | ||
|
|
650c5a00a6 | ||
|
|
e1beafb359 | ||
|
|
365e7f2896 | ||
|
|
f9a6c10719 | ||
|
|
21603e6c81 | ||
|
|
7a49c49ade | ||
|
|
110bb50e23 | ||
|
|
a672bd70dd | ||
|
|
27141b8722 | ||
|
|
e4c03b5cfd | ||
|
|
4de410c7c8 | ||
|
|
fe33dec0bb | ||
|
|
5e4a282f90 | ||
|
|
62aceb205c | ||
|
|
3979872283 | ||
|
|
2ac3ba2b86 | ||
|
|
734afd5ca3 | ||
|
|
c570414873 | ||
|
|
e61ed740c3 | ||
|
|
6371211f3b | ||
|
|
660fe22de8 | ||
|
|
5b9af8103a | ||
|
|
9fe75fa85c | ||
|
|
14bcdaf65b | ||
|
|
1c19bf160a | ||
|
|
9d6f43c149 | ||
|
|
db7efb7923 | ||
|
|
f08c970588 | ||
|
|
53bc86fdb4 | ||
|
|
e3cdb187ac | ||
|
|
2b34560de0 | ||
|
|
0e6fa89ad6 | ||
|
|
085f8bc9d2 | ||
|
|
c26e30dd1f | ||
|
|
01b6936c27 | ||
|
|
0fd9709fdb | ||
|
|
f769f4ea5d | ||
|
|
d875efd6e1 | ||
|
|
99959e4b21 | ||
|
|
58af33aa1d | ||
|
|
4ab69b877e | ||
|
|
806b1a6972 | ||
|
|
845aa6848f | ||
|
|
208195765c | ||
|
|
138d9499a7 | ||
|
|
b1f3c819b4 | ||
|
|
cf583304d1 | ||
|
|
fcb1d81ba6 | ||
|
|
bab78767ca | ||
|
|
777def98b7 | ||
|
|
0e36a44137 | ||
|
|
0724aafbb6 | ||
|
|
7652439b40 | ||
|
|
4c5809302d | ||
|
|
2bb7646a39 | ||
|
|
7d79a32748 | ||
|
|
f8064ba3ce | ||
|
|
add27cb8fc | ||
|
|
4b260c364d | ||
|
|
d1a6926f63 | ||
|
|
980a3f651c | ||
|
|
15675760c5 | ||
|
|
4b311f741e | ||
|
|
77aa384bca | ||
|
|
1c44fb766f | ||
|
|
be55c5457f | ||
|
|
6a214253a8 | ||
|
|
cfe0a779f0 | ||
|
|
c1a3d04e9e | ||
|
|
9c40a2643f | ||
|
|
d19fd80390 | ||
|
|
16af3cc3fa | ||
|
|
15beddbb74 | ||
|
|
d73e6a7648 | ||
|
|
4a2b2bb36a | ||
|
|
d906a59287 | ||
|
|
a59d8533b7 | ||
|
|
b9f4d9bd24 | ||
|
|
44eddacc23 | ||
|
|
d3f8ee14cc | ||
|
|
e60487755b | ||
|
|
eb20ee1fbc | ||
|
|
77b247dea5 | ||
|
|
0e5e83cf5c | ||
|
|
66fd1b78a2 | ||
|
|
996111b179 | ||
|
|
553f83e5bf | ||
|
|
30ddaa78ff | ||
|
|
f455e362aa | ||
|
|
06d3e52c24 | ||
|
|
8fbb09bf4e | ||
|
|
21459f4df4 | ||
|
|
9e3aa73819 | ||
|
|
6f4049494d | ||
|
|
29bad213f8 | ||
|
|
7f35522b15 | ||
|
|
3b60d0ceb6 | ||
|
|
947b294f87 | ||
|
|
434b47d9c4 | ||
|
|
a08ca07c69 | ||
|
|
d17c07f343 | ||
|
|
70e99987e9 | ||
|
|
7a85f6050d | ||
|
|
cc2f45b129 | ||
|
|
fe8af48fa7 | ||
|
|
5a855971aa | ||
|
|
36494d0385 | ||
|
|
2cfee9b99b | ||
|
|
90bd91986a | ||
|
|
3fb51869ac | ||
|
|
436d7ecdd3 | ||
|
|
fba3702103 | ||
|
|
7c7a7dcdc9 | ||
|
|
339b01f48b | ||
|
|
6d07135456 | ||
|
|
ed80181d70 | ||
|
|
24eaf5e037 | ||
|
|
4603ebec7a | ||
|
|
8d881cc0bc | ||
|
|
2ecc83e700 | ||
|
|
731c30ff6d | ||
|
|
71b7ae3d56 | ||
|
|
6eaaccb717 | ||
|
|
805d30e284 | ||
|
|
4505ccb20d | ||
|
|
433c8d5aa7 | ||
|
|
c3719b2e79 | ||
|
|
9aed6191d4 | ||
|
|
9b12fff215 | ||
|
|
e428f38767 | ||
|
|
da735914b6 | ||
|
|
2adf4465d6 | ||
|
|
d3c997329d | ||
|
|
1637165600 | ||
|
|
1f5073e4d1 | ||
|
|
288e93c11f | ||
|
|
a809f270eb | ||
|
|
22ff29167e | ||
|
|
171c382618 | ||
|
|
e3e6869043 | ||
|
|
c7522b7f0c | ||
|
|
4eaa5f0b56 | ||
|
|
f68f66a579 | ||
|
|
0c6814e760 | ||
|
|
87a9fb06cd | ||
|
|
dea33fe5b4 | ||
|
|
d9c48bddef | ||
|
|
3d4caa917a | ||
|
|
fbc1c494b2 | ||
|
|
b08482d640 | ||
|
|
255fefe71f | ||
|
|
f5fab833d0 | ||
|
|
423cb18f40 | ||
|
|
65a4847eed | ||
|
|
6daba37d3b | ||
|
|
6fc725d2fa | ||
|
|
34df89e8e0 | ||
|
|
b192e2cf30 | ||
|
|
93277ef87a | ||
|
|
58c874b128 | ||
|
|
f4035c98d1 | ||
|
|
369948f816 | ||
|
|
1726c600d2 | ||
|
|
5aa1605c17 | ||
|
|
d79aa607cc | ||
|
|
3bd04a082e | ||
|
|
ae9cd2a03c | ||
|
|
3ce08507c1 | ||
|
|
1f3d469c33 | ||
|
|
eeb78520e1 | ||
|
|
eb189a36c3 | ||
|
|
22690c3565 | ||
|
|
fad7248e7d | ||
|
|
2afd0b659c | ||
|
|
0089e7bb8b | ||
|
|
539759429c | ||
|
|
08ab1676b0 | ||
|
|
eb510f4194 | ||
|
|
cb9951918a | ||
|
|
82b22af43c | ||
|
|
9bcac8f1e6 | ||
|
|
8c69173bb7 | ||
|
|
c7d29e5b92 | ||
|
|
b9aca50c3c | ||
|
|
a2314cf803 | ||
|
|
6c04fb56c8 | ||
|
|
0f162176be | ||
|
|
91eeb5610c | ||
|
|
632cff0068 | ||
|
|
1992239325 | ||
|
|
ded39b51cc | ||
|
|
5e9b7d09a2 | ||
|
|
a75b465419 | ||
|
|
aa9f0153a4 | ||
|
|
af98309c01 | ||
|
|
5dcc64e757 | ||
|
|
abfcd0800e | ||
|
|
fe5955e521 | ||
|
|
fe9838f296 | ||
|
|
9896eda208 | ||
|
|
aecf88c1c1 | ||
|
|
2c8b8b0cc1 | ||
|
|
f79858c12c | ||
|
|
8957545c33 | ||
|
|
bc762eee99 | ||
|
|
cfa36fc55f | ||
|
|
a3b6c3d969 | ||
|
|
7ff0269a1d | ||
|
|
cd1fac5393 | ||
|
|
525b79a51a | ||
|
|
6ca54579cd | ||
|
|
558b8b621c | ||
|
|
012fe675a2 | ||
|
|
12ad39e7cc | ||
|
|
f657110510 | ||
|
|
b10d6c63ee | ||
|
|
3eeb22179d | ||
|
|
b0ba64a940 | ||
|
|
050e355e6c | ||
|
|
b1e4c4e522 | ||
|
|
c7b46dbb3e | ||
|
|
4d52b043cf | ||
|
|
2b93e8a930 | ||
|
|
cc6fd8a5dc | ||
|
|
cd34e4097a | ||
|
|
d10962ac45 | ||
|
|
a31cdab606 | ||
|
|
975dea056c | ||
|
|
0a8d334510 | ||
|
|
a2abe63e8d | ||
|
|
0ff9e35374 | ||
|
|
e02c2dc899 | ||
|
|
8e4158b364 | ||
|
|
008644ea2c | ||
|
|
a8eb9024da | ||
|
|
05bc89827b | ||
|
|
e3978d158a | ||
|
|
83ad194819 | ||
|
|
61c333b8ed | ||
|
|
0cfdefa853 | ||
|
|
6c3f9f60aa | ||
|
|
6fd342563d | ||
|
|
8a31fda88f | ||
|
|
c4e139af19 | ||
|
|
389f75fa7b | ||
|
|
34eb19a5ab | ||
|
|
0c57b1d5d2 | ||
|
|
5d847ffc88 | ||
|
|
2a52b2901f | ||
|
|
7f4f7bd8d2 | ||
|
|
2729483851 | ||
|
|
5226d5a728 | ||
|
|
dec085e1e5 | ||
|
|
eaf96c60ae | ||
|
|
85bbc35176 | ||
|
|
3ce51a3487 | ||
|
|
a031ff6c7b | ||
|
|
0d748500d6 | ||
|
|
d47857a20f | ||
|
|
c9236ed3f7 | ||
|
|
2480a505cb | ||
|
|
231a97dd51 | ||
|
|
1819921e03 | ||
|
|
06226bcb73 | ||
|
|
20eaecd624 | ||
|
|
653945c8f8 | ||
|
|
bfde4dd918 | ||
|
|
a71348821a | ||
|
|
4f60296e77 | ||
|
|
98ccc7980b | ||
|
|
1639666a34 | ||
|
|
47af13c3d4 | ||
|
|
1da3c0c052 | ||
|
|
b19731b307 | ||
|
|
2d95ac1694 | ||
|
|
68241453dd | ||
|
|
39099dcc57 | ||
|
|
9ea392e6ee | ||
|
|
fffec4a714 | ||
|
|
adfe6e3820 | ||
|
|
935a588aa2 | ||
|
|
212fb9994a | ||
|
|
f5111045a9 | ||
|
|
c3e0ca3dfa | ||
|
|
50dbe4c10e | ||
|
|
ec41d72e0d | ||
|
|
63026402a1 | ||
|
|
a785b53e7b | ||
|
|
1217d58ca1 | ||
|
|
c912ef59d1 | ||
|
|
af505b197a | ||
|
|
f043a1a392 | ||
|
|
d01d7b2ef0 | ||
|
|
dc6e8a4253 | ||
|
|
327737b21a | ||
|
|
a7a4d952ea | ||
|
|
621e1a6640 | ||
|
|
c5274beb23 | ||
|
|
611f6855ef | ||
|
|
75c587c8a0 | ||
|
|
10f1c56a86 | ||
|
|
ab49f6af8b | ||
|
|
51e24f2944 | ||
|
|
2048084396 | ||
|
|
cee73d03a3 | ||
|
|
3ee9543b3f | ||
|
|
c183c47df3 | ||
|
|
2d2e0c6992 | ||
|
|
2972e2e6bf | ||
|
|
02e93902b3 | ||
|
|
d7339804e6 | ||
|
|
33d6d38ec8 | ||
|
|
53f8516cc6 | ||
|
|
db39545a85 | ||
|
|
aa17dd7d2c | ||
|
|
b26612142f | ||
|
|
a7279231cb | ||
|
|
77667945b1 | ||
|
|
faa3678a3f | ||
|
|
2ddbd9293f | ||
|
|
f5bf327447 | ||
|
|
1622a70f85 | ||
|
|
cd59b9cc94 | ||
|
|
1b3cf83e04 | ||
|
|
580ada65d5 | ||
|
|
7abce8426a | ||
|
|
0eb1e463b9 | ||
|
|
0b0ccfb35a | ||
|
|
0abb4157c9 | ||
|
|
96344f0761 | ||
|
|
a2530621fc | ||
|
|
d93e4e9cf1 | ||
|
|
a22bced150 | ||
|
|
a9724275cc | ||
|
|
04e714e9dd | ||
|
|
adad531adb | ||
|
|
c94d38e53c | ||
|
|
340200a6ec | ||
|
|
d5bc4ceeee | ||
|
|
a3316d2383 | ||
|
|
c6486ea50d | ||
|
|
145ae2e877 | ||
|
|
f76c834066 | ||
|
|
d23973de4f | ||
|
|
bd55d66072 | ||
|
|
9ea05d5d45 | ||
|
|
b5d25c1102 | ||
|
|
c11b516d34 | ||
|
|
2bfb0887d8 | ||
|
|
b2800fb529 | ||
|
|
1b761c68c1 | ||
|
|
683b1e9845 | ||
|
|
a0438f73f9 | ||
|
|
b23a402df8 | ||
|
|
fd52ca32c7 | ||
|
|
0497c9e46d | ||
|
|
cacc9e4f36 | ||
|
|
33b6a6323d | ||
|
|
148ef1870a | ||
|
|
7f17d980d4 | ||
|
|
eeb213e5a8 | ||
|
|
e77d15c70d | ||
|
|
ed14640756 | ||
|
|
3416bf49cd | ||
|
|
178f99d60e | ||
|
|
c403e57d2a | ||
|
|
e606c56064 | ||
|
|
d15bc61818 | ||
|
|
428894df22 | ||
|
|
0cb1beaf21 | ||
|
|
c05ad3985c | ||
|
|
05bb1ac97f | ||
|
|
7915bfc0e1 | ||
|
|
219b9da346 | ||
|
|
683f6439ca | ||
|
|
67a0ffca4d | ||
|
|
e0d494b2eb | ||
|
|
9e09d53fd9 | ||
|
|
6d5fdc8a2a | ||
|
|
057c2ee2c1 | ||
|
|
5a9c6e2e06 | ||
|
|
130bacde12 | ||
|
|
311d75fc97 | ||
|
|
c845c7f9e0 | ||
|
|
2ff5688511 | ||
|
|
0d697ed79d | ||
|
|
57050b0987 | ||
|
|
743fe2521e | ||
|
|
6bf2a42e22 | ||
|
|
93cadc8994 | ||
|
|
2f984f2e7a | ||
|
|
2f458842ae | ||
|
|
3b742b6c0c | ||
|
|
de17a00eb2 | ||
|
|
059bd82676 | ||
|
|
6eeabcd742 | ||
|
|
bfbe9e1eb6 | ||
|
|
ac6d72b44a | ||
|
|
004d015cfe | ||
|
|
e860b01ed8 | ||
|
|
4c011cf42e | ||
|
|
6f7f32c64d | ||
|
|
59c890df9e | ||
|
|
d4b0a5479b | ||
|
|
7989d1345f | ||
|
|
f443dde16f | ||
|
|
c36b6976e3 | ||
|
|
01e165af63 | ||
|
|
199a464140 | ||
|
|
45516a6a96 | ||
|
|
d0015a496b | ||
|
|
2e07f887bf | ||
|
|
500e19680d | ||
|
|
b3fa281282 | ||
|
|
f2252b0d9c | ||
|
|
06dbe5b1e8 | ||
|
|
5a638b0a80 | ||
|
|
eb0f2ff2e4 | ||
|
|
202894372a | ||
|
|
c89906a13a | ||
|
|
ba2069c057 | ||
|
|
555b0e60d3 | ||
|
|
e3874e89e7 | ||
|
|
8a0cd9dbf6 | ||
|
|
f147eb58c7 | ||
|
|
3a48de555e | ||
|
|
d7fb5ec727 | ||
|
|
6794385731 | ||
|
|
bea68002e9 | ||
|
|
2f97c8ab49 | ||
|
|
2b000d828e | ||
|
|
a644c818c9 | ||
|
|
194968bc27 | ||
|
|
58bb72ffc6 | ||
|
|
6315086f96 | ||
|
|
2e643b0e76 | ||
|
|
bd895a1de7 | ||
|
|
12f8a77a00 | ||
|
|
9f659195c3 | ||
|
|
258d61ced0 | ||
|
|
5ff1b97376 | ||
|
|
9216c47204 | ||
|
|
4eaf5e7b50 | ||
|
|
068a934f6a | ||
|
|
079580257a | ||
|
|
f81c0f51d7 | ||
|
|
01a65c44ab | ||
|
|
11c3a99bfb | ||
|
|
30ba774e6f | ||
|
|
32a688d68b | ||
|
|
5d07d646a9 | ||
|
|
22443d8711 | ||
|
|
a0cc4a4b94 | ||
|
|
f0349fb2f8 | ||
|
|
d428de6caa | ||
|
|
6a13088840 | ||
|
|
18c9d27c8d | ||
|
|
3b32ea9626 | ||
|
|
42b198e3cb | ||
|
|
35c4a428ff | ||
|
|
1446b4f5dc | ||
|
|
79dd751d3b | ||
|
|
7d6be31711 | ||
|
|
2bc6c66c94 | ||
|
|
5b198f3c00 | ||
|
|
471c47f96a | ||
|
|
bb778a50aa | ||
|
|
7241726e56 | ||
|
|
3bdaa45607 | ||
|
|
b4c89f7301 | ||
|
|
20c1a1621e | ||
|
|
d57134a308 | ||
|
|
9449c92136 | ||
|
|
d62ecfe33a | ||
|
|
c738400ebb | ||
|
|
0044143880 |
No files matched your search
@@ -24,9 +24,9 @@ jobs:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: "1.95"
|
||||
- name: Fetch pinned and checksum-verified corpus
|
||||
|
||||
@@ -29,16 +29,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
|
||||
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -67,16 +67,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Desktop printing
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "apps/vectorcraft/Cargo.toml"
|
||||
- "apps/vectorcraft/src/printing.rs"
|
||||
- "apps/vectorcraft/src/printing/**"
|
||||
- "crates/ui-egui/src/print.rs"
|
||||
- "crates/ui-egui/src/tests_printps.rs"
|
||||
- ".github/workflows/print.yml"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: desktop-print
|
||||
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
|
||||
- run: cargo test --locked -p vectorcraft
|
||||
- name: Check pending submission and queue refusal feedback
|
||||
run: cargo test --locked -p vectorcraft-ui-egui tests_printps
|
||||
+116
-36
@@ -43,7 +43,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -71,18 +71,18 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -130,18 +130,18 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -191,13 +191,13 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
@@ -208,8 +208,8 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -259,8 +259,8 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -292,22 +292,22 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
@@ -344,18 +344,18 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
@@ -371,17 +371,97 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -392,7 +472,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
|
||||
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "apps/vectorcraft/Cargo.toml"
|
||||
- "apps/vectorcraft/src/printing.rs"
|
||||
- "apps/vectorcraft/src/printing/**"
|
||||
- "crates/ui-egui/src/print.rs"
|
||||
- "crates/ui-egui/src/tests_printps.rs"
|
||||
- "packaging/windows/windows7.ps1"
|
||||
- ".github/workflows/windows7.yml"
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
@@ -56,10 +56,15 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
|
||||
|
||||
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
@@ -83,6 +83,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/copy.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/dc-actions.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-al-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-al-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-al-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-al-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-al-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -98,6 +99,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | The Selection and Direct Selection arrows' fill, painted black or white under their outline |
|
||||
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
@@ -56,8 +56,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
|
||||
|
||||
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
Generated
+222
-29
@@ -200,6 +200,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -713,6 +719,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -790,6 +806,23 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -861,6 +894,19 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1066,6 +1112,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -1438,6 +1505,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1792,11 +1868,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -1846,6 +1943,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1853,6 +1952,29 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"icu_collections",
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
"icu_segmenter_data",
|
||||
"potential_utf",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1940,6 +2062,25 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2067,6 +2208,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
@@ -2119,6 +2270,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2167,9 +2324,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
@@ -2740,6 +2897,16 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -3039,6 +3206,8 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4020,6 +4189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4320,11 +4490,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
@@ -4335,11 +4506,15 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
@@ -4349,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4360,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4372,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4388,7 +4563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4405,11 +4580,12 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"windows",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4421,7 +4597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4438,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4448,11 +4624,12 @@ dependencies = [
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4487,18 +4664,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -4506,7 +4685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4520,7 +4699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4532,7 +4711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4551,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4565,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4577,7 +4756,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4606,7 +4785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4620,7 +4799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4645,7 +4824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4669,7 +4848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4686,9 +4865,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
@@ -4696,15 +4876,24 @@ dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4719,7 +4908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4727,15 +4916,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4756,13 +4946,14 @@ dependencies = [
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5872,7 +6063,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6056,6 +6247,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6064,6 +6256,7 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+5
-2
@@ -4,13 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
@@ -20,6 +22,7 @@ vectorcraft-brush = { path = "crates/brush" }
|
||||
vectorcraft-trace = { path = "crates/trace" }
|
||||
vectorcraft-text = { path = "crates/text" }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
vectorcraft-render = { path = "crates/render" }
|
||||
vectorcraft-svg = { path = "crates/svg" }
|
||||
vectorcraft-pdf = { path = "crates/pdf" }
|
||||
@@ -60,7 +63,7 @@ usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
|
||||
cargo run --release -p vectorcraft # desktop app
|
||||
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
|
||||
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
|
||||
@@ -170,12 +173,13 @@ next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low
|
||||
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
|
||||
to place artwork in the current document, copy and paste the file in a file manager; or run
|
||||
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
|
||||
to place artwork in the current document, copy and paste the file in a file manager. For file
|
||||
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
|
||||
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
@@ -198,13 +202,16 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
|
||||
|
||||
## Status
|
||||
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
|
||||
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-09):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
**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
|
||||
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, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
@@ -213,23 +220,25 @@ 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, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's 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:**
|
||||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) 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 alongside the raster-effects package, then 3D and
|
||||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
**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
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**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}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
|
||||
+135
-241
@@ -1,270 +1,164 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux and the web (WASM), and agents can drive it fully over a JSON control channel and MCP.
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
## Where we are
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
| Dimension | Status |
|
||||
|---|---|
|
||||
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
|
||||
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
|
||||
control channel. This page is the summary; the evidence is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
|
||||
and the work list in [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
|
||||
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
|
||||
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
|
||||
editing data, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
|
||||
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
|
||||
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
|
||||
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
|
||||
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
|
||||
## Headline numbers
|
||||
|
||||
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
|
||||
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
|
||||
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
|
||||
since 2026-10-01.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~10 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
| **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 44/50, Photoshop effects 29/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 |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
**Readiness by audience** (all estimated; method in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work)):
|
||||
|
||||
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 45 filters (Artistic, Brush Strokes, Distort,
|
||||
Sketch, Stylize, Texture) and the Effect Gallery. The pixel-filter pipeline is in place
|
||||
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
|
||||
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
|
||||
(Normal mode only), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
|
||||
Stylize › Glowing Edges and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
|
||||
well across agents. 23–36 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line
|
||||
composer choice is done), tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 34–47 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth (Scatter, Art and Pattern Brush Options; Calligraphic
|
||||
and Bristle are done, Calligraphic with pen pressure); original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
|
||||
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
|
||||
(51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents,
|
||||
complex typography and independently paired `.af`/PSD renders reopened in Affinity remain missing), idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
|
||||
Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's merged-PR timestamps (e.g. 0.7–1.2 h per
|
||||
Photoshop-style filter, ~0.8 h per medium feature); see [calibration](docs/target-app-parity.md#calibration).
|
||||
The installed Illustrator was **not** inspected: the clean-room rule in [`AGENTS.md`](AGENTS.md) forbids it, so
|
||||
everything is measured from our code against Illustrator's public documentation.
|
||||
|
||||
### Where we're going
|
||||
## By dimension
|
||||
|
||||
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
|
||||
so they split well across agents, and they move the "can't tell the difference" number most.
|
||||
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
|
||||
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
|
||||
budget it misses before 1.0.
|
||||
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
|
||||
| Dimension | % | One agent (h) | Doc |
|
||||
|---|---:|---:|---|
|
||||
| Features (depth) | 75% | 220–360 | [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
| Ecosystem and plug-ins | 15% | 30–60 | [gaps.md › G17](docs/gaps.md#g17-ecosystem-third-party-plug-ins) |
|
||||
| AI features (generative) | 0% | 30–60 + owner decision | [gaps.md › G18](docs/gaps.md#g18-generative-ai-features) |
|
||||
| Agent automation | beyond Illustrator | — | [mcp.md](docs/mcp.md) |
|
||||
|
||||
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
|
||||
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
|
||||
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
|
||||
The dimension hours overlap (feature rows hold some UI, format and AI work), so they don't add up to the total.
|
||||
|
||||
## Shipped so far
|
||||
## Features
|
||||
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Still to come: Scatter, Art and Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
| Area | % | One agent (h) |
|
||||
|---|---:|---:|
|
||||
| Selection, transform & align | 85% | 4–6 |
|
||||
| Drawing tools | 85% | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 80% | 4–8 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 |
|
||||
| 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% | 23–39 |
|
||||
| 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 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
| Guides, grids, smart guides, snapping | 80% | 4–7 |
|
||||
| File formats — [file-format-parity.md](docs/file-format-parity.md) | 75% | 10–20 |
|
||||
| Export for Screens, Asset Export, slices | 85% | 2–4 |
|
||||
| Print, colour management, separations | 80% | 2–4 |
|
||||
| Automation (Actions, Variables, scripting) | 60% | 8–14 |
|
||||
| UI chrome (panels, Properties, preferences) | 80% | 13–20 |
|
||||
| Libraries, Links, Package | 82% | 2–3 |
|
||||
| Generative AI | 0% | 30–60 |
|
||||
|
||||
## Milestones and estimates
|
||||
Weights, evidence and the missing items per area: [target-app-parity.md](docs/target-app-parity.md#feature-areas).
|
||||
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
## Languages
|
||||
|
||||
## Parity estimate
|
||||
| Language | Code | Status | Translated |
|
||||
|---|---|---|---:|
|
||||
| English | `en` | full | 100% |
|
||||
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
|
||||
| Spanish | `es` | full | 100% |
|
||||
| Hindi | `hi` | none | 0% |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | partial | 68% |
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
|
||||
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
|
||||
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
|
||||
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
|
||||
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
|
||||
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
|
||||
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
|
||||
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
|
||||
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian 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).
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 89% | Scatter, Art and Pattern Brush Options (Calligraphic and Bristle done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 10–18 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 29% | ~45 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize, Texture), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize) and Video (De-Interlace, NTSC Colors) are done | 23–36 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
## Upcoming
|
||||
|
||||
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
|
||||
**~80–145 h** to full parity.
|
||||
Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
1. **Interaction-fidelity pass** (G1), tool by tool from [ui-parity.md](docs/ui-parity.md): 60–90 h.
|
||||
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: 28 filters and the Effect Gallery, 23–39 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
|
||||
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
## Progress log
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
|
||||
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 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-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 |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
| 2026-10-07 | v0.4.0; issue fixes; Layers panel rework |
|
||||
| 2026-10-06 | M8 transform and distort fidelity pass (M8.1–M8.20) |
|
||||
| 2026-10-05 | M4 files done (M4.14–M4.98); M3 paint and appearance done (92 tasks over two days) |
|
||||
| 2026-10-02 | First weighted parity estimate (22 areas); look and feel measured against public screenshots |
|
||||
| 2026-10-01 | Renamed from DrawCraft to VectorCraft |
|
||||
| 2026-09-30 | Repository history begins |
|
||||
|
||||
## Where we aim to be better than Illustrator
|
||||
- **Speed:** off-thread multithreaded rendering, instant startup, a responsive UI on huge files.
|
||||
- **Robustness:** exact curve booleans (no "cannot perform operation"), property-tested undo/redo and file round trips.
|
||||
- **Openness:** a documented native format, first-class SVG, and the same app in the browser.
|
||||
- **Automation:** every command, gesture, dialog and widget is drivable by agents (JSON channel + MCP), with headless batch mode.
|
||||
The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard).
|
||||
|
||||
## How to update this file
|
||||
After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity.
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
|
||||
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
|
||||
| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha |
|
||||
| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` |
|
||||
| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list |
|
||||
| 2026-10-02 | major | First 22-area weighted parity estimate |
|
||||
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
|
||||
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
|
||||
# which the desktop app's src/gpu.rs also uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
|
||||
|
||||
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
|
||||
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
//! ```
|
||||
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
||||
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
||||
@@ -84,14 +84,16 @@ USAGE:
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
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
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
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. OUT may not be IN when reading
|
||||
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.
|
||||
--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.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -102,9 +104,11 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows).
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -215,7 +219,9 @@ fn commands() -> Result<(), String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
|
||||
(1.0f64, None::<u64>, None::<String>, false, false, false);
|
||||
let mut decimals = None::<u64>;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
@@ -223,16 +229,26 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
|
||||
"--replace-lossy" => replace_lossy = true,
|
||||
"--editing-data" => editing_data = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let mut h = Headless::new();
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
} 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 params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
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 r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
@@ -292,7 +308,21 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
let mut closed = false;
|
||||
let mut emit = |v: Value| {
|
||||
if closed {
|
||||
return Ok(());
|
||||
}
|
||||
match writeln!(out, "{v}") {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
|
||||
closed = true;
|
||||
Ok(())
|
||||
}
|
||||
r => r.map_err(|e| e.to_string()),
|
||||
}
|
||||
};
|
||||
if let Some(path) = &input {
|
||||
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
|
||||
emit(json!({"step": "open", "path": path, "result": r}))?;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
|
||||
//! average above three quarters of the cores, or on Windows more than three quarters of the
|
||||
//! cores busy over a second) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// The cores busy on average over one second (Windows), if available: the share of the
|
||||
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
|
||||
/// times `cores`. A load average also counts the threads waiting for a core.
|
||||
#[cfg(windows)]
|
||||
fn busy_cores(cores: usize) -> Option<f64> {
|
||||
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
|
||||
let before = times()?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
busy_share(before, times()?).map(|share| share * cores as f64)
|
||||
}
|
||||
|
||||
/// The share of the processors' time between two `[idle, kernel, user]` readings of
|
||||
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
|
||||
/// passed, a time went back, or the idle time exceeds the kernel and user time.
|
||||
#[cfg(any(windows, test))]
|
||||
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
|
||||
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
|
||||
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
|
||||
(total > 0).then(|| busy as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
|
||||
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
|
||||
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
@@ -113,18 +136,171 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
Ok((handle, segment))
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut rows: Vec<(String, f64)> = vec![];
|
||||
let mut mem: Vec<(String, f64)> = vec![];
|
||||
let mut note = |what: &str| {
|
||||
if let Some(m) = rss_mb() {
|
||||
mem.push((what.to_string(), m));
|
||||
}
|
||||
};
|
||||
note("start");
|
||||
// Editing every object of an n-path document.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(n)?, None);
|
||||
note("document built");
|
||||
rows.push((format!("select all, {n} paths"), median_ms(3, || drop(s.execute("select.all", &json!({}))))));
|
||||
rows.push((format!("nudge the selection ({n} paths)"), median_ms(5, || drop(s.execute("object.move", &json!({"dx": 1, "dy": 0}))))));
|
||||
rows.push((format!("undo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.undo", &json!({}))))));
|
||||
rows.push((format!("redo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.redo", &json!({}))))));
|
||||
// Dragging the whole selection with the Selection tool: one pointer move (the preview).
|
||||
{
|
||||
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_geom::Vec2;
|
||||
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
|
||||
let first = s.doc().map_err(err)?.selection.objects.first().copied();
|
||||
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
|
||||
if let Some(down) = at {
|
||||
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
|
||||
s.select_tool("selection", view).map_err(err)?;
|
||||
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
|
||||
let mut dx = 0.0;
|
||||
rows.push((
|
||||
format!("drag the selection, per move ({n} paths)"),
|
||||
median_ms(5, || {
|
||||
dx += 2.0;
|
||||
let _ = s.pointer(&ev(PointerKind::Drag, dx), view);
|
||||
}),
|
||||
));
|
||||
s.pointer(&ev(PointerKind::Up, dx), view).map_err(err)?;
|
||||
}
|
||||
}
|
||||
for _ in 0..40 {
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).map_err(err)?;
|
||||
}
|
||||
note("after 45 whole-selection edits");
|
||||
drop(s);
|
||||
// Live effects on 2,000 paths: render a page whose every object has the effect.
|
||||
let opts = RenderOptions::default();
|
||||
for (fx, params) in [("path.offsetPath", json!({"offset": 3})), ("distort.roughen", json!({"size": 5, "relative": true}))] {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(2000)?, None);
|
||||
s.execute("select.all", &json!({})).map_err(err)?;
|
||||
s.execute("effect.apply", &json!({"effect": fx, "params": params})).map_err(err)?;
|
||||
let doc = s.doc().map_err(err)?.doc.clone();
|
||||
let mut r = Renderer::new();
|
||||
r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts);
|
||||
rows.push((format!("render, 2,000 paths with {fx} (warm)"), median_ms(5, || drop(r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts)))));
|
||||
}
|
||||
note("after live effects");
|
||||
// Every Pathfinder operation on 300 overlapping paths.
|
||||
for op in ["unite", "intersect", "exclude", "minusFront", "minusBack", "divide", "trim", "merge", "crop", "outline"] {
|
||||
let ms = median_ms(1, || {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
if let Ok(d) = synthetic(300) {
|
||||
s.add_document(d, None);
|
||||
let _ = s.execute("select.all", &json!({})).and_then(|_| s.execute(&format!("object.pathfinder.{op}"), &json!({})));
|
||||
}
|
||||
});
|
||||
rows.push((format!("Pathfinder {op}, 300 paths"), ms));
|
||||
}
|
||||
note("after Pathfinder");
|
||||
// Typing one character in the middle of a 20,000-character area-type story.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(Document::new(1600.0, 1200.0), None);
|
||||
let text: String = (0..2000).map(|i| if i % 12 == 11 { "paragraph.\n" } else { "lorem ips " }).collect();
|
||||
let id = s.execute("text.create", &json!({"x": 20, "y": 20, "text": text, "size": 10, "area": {"width": 1500, "height": 1100}})).map_err(err)?;
|
||||
let id = id.get("id").cloned().unwrap_or(json!(0));
|
||||
let mut at = 10_000;
|
||||
rows.push((
|
||||
"type one character, 20,000-character area text".into(),
|
||||
median_ms(9, || {
|
||||
at += 1;
|
||||
let _ = s.execute("text.editRange", &json!({"id": id, "start": at, "end": at, "insert": "x"}));
|
||||
}),
|
||||
));
|
||||
note("after typing");
|
||||
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
|
||||
// worker and a panel each draw them.
|
||||
{
|
||||
let art = synthetic(3000)?;
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let l = d.layers.first().map(|l| l.id);
|
||||
for i in 0..4u32 {
|
||||
let photo = Renderer::new().render(&art, 6000, 4000, Affine::scale(3.75 + f64::from(i) * 0.1), &opts).to_png()?;
|
||||
let key = format!("photo {i}");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
|
||||
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
|
||||
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
|
||||
let id = d.alloc_id();
|
||||
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
|
||||
}
|
||||
note("4 embedded 24 MP photos");
|
||||
let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()];
|
||||
let ms = median_ms(1, || {
|
||||
for r in &mut renderers {
|
||||
r.render(&d, 2880, 1800, Affine::scale(1.6), &opts);
|
||||
}
|
||||
});
|
||||
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
|
||||
note("after rendering the photos with 3 renderers");
|
||||
}
|
||||
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
|
||||
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
|
||||
let doc = synthetic(n)?;
|
||||
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
|
||||
let saved = vectorcraft_format::save_file(&doc);
|
||||
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
|
||||
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
|
||||
outln!(" tracked (no budget yet)");
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
}
|
||||
// `rss_mb` reads the working set on Windows.
|
||||
let memory = if cfg!(windows) { "working set" } else { "memory" };
|
||||
for (what, mb) in &mem {
|
||||
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut n = 50_000usize;
|
||||
let mut want_deep = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--deep" => want_deep = true,
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
let (load, label) = (load_average(), "load average");
|
||||
#[cfg(windows)]
|
||||
let (load, label) = (busy_cores(cores), "cores busy");
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
@@ -200,6 +376,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if let Some(e) = failed {
|
||||
return Err(e);
|
||||
}
|
||||
if want_deep {
|
||||
deep(n)?;
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
@@ -217,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
// 10,000,000 of them idle.
|
||||
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
|
||||
// There is no share when no time passed, a time went back, or the idle time exceeds the
|
||||
// kernel and user time.
|
||||
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
|
||||
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
|
||||
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
|
||||
}
|
||||
|
||||
/// On Windows `busy_cores` gives a number between 0 and the cores.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_how_many_cores_are_busy() {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let busy = busy_cores(cores);
|
||||
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
|
||||
}
|
||||
|
||||
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
|
||||
/// 10,000 MB.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,15 @@ fn mcp_automation_roots() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||
std::fs::write(base.join("in/a.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/victim.png"), b"keep this file").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
symlink(base.join("outside/b.svg"), base.join("in/escape.svg")).unwrap();
|
||||
symlink(base.join("outside"), base.join("out/escape")).unwrap();
|
||||
symlink(base.join("outside/victim.png"), base.join("out/victim.png")).unwrap();
|
||||
symlink(base.join("outside/missing.png"), base.join("out/dangling.png")).unwrap();
|
||||
}
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let fails = |args: &[&str], why: &str| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
@@ -114,15 +123,32 @@ fn mcp_automation_roots() {
|
||||
.unwrap();
|
||||
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
||||
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||
let mut calls = vec![
|
||||
call(1, "open_file", json!({"path": file("in", "a.svg")})),
|
||||
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
|
||||
call(3, "export", json!({"path": file("out", "a.png")})),
|
||||
call(4, "export", json!({"path": file("in", "a.png")})),
|
||||
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
|
||||
call(6, "open_file", json!({"path": file("in", "../outside/b.svg")})),
|
||||
call(7, "export", json!({"path": file("out", "../outside/traversal.png")})),
|
||||
];
|
||||
let mut expected_errors = vec![false, true, false, true, true, true, true];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
calls.extend([
|
||||
call(8, "open_file", json!({"path": file("in", "escape.svg")})),
|
||||
call(9, "export", json!({"path": file("out", "escape/escaped.png")})),
|
||||
call(10, "export", json!({"path": file("out", "victim.png")})),
|
||||
call(11, "export", json!({"path": file("out", "dangling.png")})),
|
||||
]);
|
||||
expected_errors.extend([true; 4]);
|
||||
}
|
||||
// An access error is per request: the server continues and can still save inside its root.
|
||||
calls.push(call(12, "save_file", json!({"path": file("out", "allowed.vectorcraft")})));
|
||||
expected_errors.push(false);
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for m in [
|
||||
call(1, "open_file", json!({"path": file("in", "a.svg")})),
|
||||
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
|
||||
call(3, "export", json!({"path": file("out", "a.png")})),
|
||||
call(4, "export", json!({"path": file("in", "a.png")})),
|
||||
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
|
||||
] {
|
||||
for m in calls {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -130,9 +156,14 @@ fn mcp_automation_roots() {
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
|
||||
assert_eq!(errors, expected_errors, "{replies:?}");
|
||||
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
|
||||
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
|
||||
assert!(base.join("out/allowed.vectorcraft").is_file());
|
||||
for denied in ["traversal.png", "escaped.png", "missing.png"] {
|
||||
assert!(!base.join("outside").join(denied).exists(), "{denied}");
|
||||
}
|
||||
assert_eq!(std::fs::read(base.join("outside/victim.png")).unwrap(), b"keep this file");
|
||||
}
|
||||
|
||||
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
|
||||
@@ -297,3 +328,21 @@ fn closed_stdout_ends_quietly() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
|
||||
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
|
||||
/// and its later steps still run; a failing step still fails.
|
||||
#[test]
|
||||
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let svg = tmp("closed-run.svg");
|
||||
let _ = std::fs::remove_file(&svg);
|
||||
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
|
||||
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
|
||||
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
|
||||
assert!(svg.is_file(), "the export after the closed reader still ran");
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_perf_deep() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
#[test]
|
||||
fn info_reports_why_an_eps_read_partly() {
|
||||
|
||||
@@ -205,7 +205,8 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
|
||||
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
|
||||
if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) {
|
||||
// Blank text is not a caption. A picture pasted with it is still the picture.
|
||||
if data.get_data("text").is_ok_and(|t| !t.trim().is_empty() && !is_address(&t)) {
|
||||
return;
|
||||
}
|
||||
e.prevent_default();
|
||||
@@ -308,7 +309,8 @@ fn notice(lines: &[&str]) {
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
@@ -401,7 +403,10 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
let inbox = picked.clone();
|
||||
let ctx = place_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let dialog =
|
||||
fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
|
||||
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
|
||||
});
|
||||
let Some(files) = dialog.pick_files().await else {
|
||||
return;
|
||||
};
|
||||
@@ -418,7 +423,9 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| {
|
||||
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
|
||||
});
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,10 @@ webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
|
||||
craft-print = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
|
||||
@@ -51,15 +55,27 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
|
||||
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
|
||||
raw-window-handle = "0.6"
|
||||
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
|
||||
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
|
||||
# objc2-foundation.
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
|
||||
# already in the build through eframe.
|
||||
pollster = "1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# 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"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Parse the control port and automation roots before starting any desktop services. A malformed
|
||||
//! flag must not silently remove a root and leave the control channel unconfined, or leave the app
|
||||
//! running without the control channel that was asked for.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. CLI values override environment defaults
|
||||
/// once; a second occurrence is an error, including when the two spellings are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `--control <port>` or `--control=<port>`, given at most once.
|
||||
#[derive(Default)]
|
||||
pub struct ControlArg {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl ControlArg {
|
||||
/// Returns whether this argument is `--control`. The next argument is its port whatever it
|
||||
/// is, so `--control --automation-read-root` reports the flag rather than reading it as a root.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let value = match arg.strip_prefix("--control") {
|
||||
Some("") => args.next(),
|
||||
Some(rest) => match rest.strip_prefix('=') {
|
||||
Some(value) => Some(value.to_owned()),
|
||||
None => return Ok(false),
|
||||
},
|
||||
None => return Ok(false),
|
||||
};
|
||||
if self.port.is_some() {
|
||||
return Err("--control given twice".into());
|
||||
}
|
||||
self.port = Some(port("--control", value.as_deref())?);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control
|
||||
/// channel, anything else must be a port.
|
||||
pub fn env_control_port(value: Option<OsString>) -> Result<Option<u16>, String> {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A port from 1 to 65535 (the server would listen on 0's random port without saying which).
|
||||
fn port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
let need = format!("{name} needs a port number from 1 to 65535");
|
||||
match value {
|
||||
None => Err(need),
|
||||
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_override_environment_once_and_leave_other_arguments() {
|
||||
let mut roots = AutomationArgs::new(Some("environment".into()), None);
|
||||
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_preserve_environment_roots() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--control"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
|
||||
fn control(args: &[&str]) -> Result<Option<u16>, String> {
|
||||
let mut control = ControlArg::default();
|
||||
let mut args = args.iter().map(|a| a.to_string());
|
||||
while let Some(a) = args.next() {
|
||||
assert!(control.consume(&a, &mut args)?, "{a}");
|
||||
}
|
||||
Ok(control.port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_needs_a_port_from_1_to_65535() {
|
||||
let need = "--control needs a port number from 1 to 65535";
|
||||
assert_eq!(control(&["--control"]), Err(need.into()));
|
||||
assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`")));
|
||||
for bad in ["abc", "0", "70000", "-1", ""] {
|
||||
assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`")));
|
||||
assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`")));
|
||||
}
|
||||
assert_eq!(control(&["--control", "7979"]), Ok(Some(7979)));
|
||||
assert_eq!(control(&["--control=65535"]), Ok(Some(65535)));
|
||||
assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_arguments_are_not_control() {
|
||||
let mut control = ControlArg::default();
|
||||
let mut args = ["7979".to_owned()].into_iter();
|
||||
for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] {
|
||||
assert!(!control.consume(other, &mut args).unwrap(), "{other}");
|
||||
}
|
||||
assert_eq!(args.next().as_deref(), Some("7979"));
|
||||
assert_eq!(control.port, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_variable_is_a_port_or_unset() {
|
||||
assert_eq!(env_control_port(None), Ok(None));
|
||||
assert_eq!(env_control_port(Some("".into())), Ok(None));
|
||||
assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979)));
|
||||
let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
|
||||
assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`")));
|
||||
assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,21 @@
|
||||
//! (`Services::system_clipboard`).
|
||||
//!
|
||||
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
|
||||
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
|
||||
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
|
||||
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
|
||||
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
cb
|
||||
}
|
||||
|
||||
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn windows_formats(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
EPS => &["Encapsulated PostScript", "application/postscript"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
|
||||
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
|
||||
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
|
||||
/// older name some apps still give PDF. Text is read through arboard.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
|
||||
match mime {
|
||||
SVG => &[("public.svg-image", SVG)],
|
||||
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
|
||||
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
|
||||
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The text flavour among `flavours`.
|
||||
fn text_of(flavours: &[Flavour]) -> Option<String> {
|
||||
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
|
||||
@@ -127,27 +156,15 @@ mod win {
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The registered clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
fn names(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
/// other or a `CF_BITMAP` is: the system converts between them.
|
||||
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
|
||||
|
||||
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
|
||||
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
@@ -350,27 +367,30 @@ mod portable {
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
return Some(f);
|
||||
}
|
||||
mimes.iter().find_map(|m| match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::bitmap() {
|
||||
return Some(f);
|
||||
mimes.iter().find_map(|m| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::read(m) {
|
||||
return Some(f);
|
||||
}
|
||||
match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba =
|
||||
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
|
||||
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
|
||||
/// menu item waits for text.
|
||||
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
|
||||
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
|
||||
/// take it, the menu item waits for text.
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
|
||||
if super::mac::has(mimes) {
|
||||
return true;
|
||||
}
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
@@ -378,7 +398,8 @@ mod portable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
/// pictures TIFF only, decoded).
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use objc2::rc::autoreleasepool;
|
||||
@@ -387,24 +408,20 @@ mod mac {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
|
||||
/// `NSPasteboardTypeTIFF`), with their MIME types.
|
||||
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
|
||||
|
||||
/// Does the pasteboard hold a bitmap? Asks for its types only.
|
||||
pub fn has_bitmap() -> bool {
|
||||
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
|
||||
pub fn has(mimes: &[&'static str]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
let types = TYPES.map(|(t, _)| NSString::from_str(t));
|
||||
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
|
||||
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The pasteboard's bitmap, PNG before TIFF.
|
||||
pub fn bitmap() -> Option<Flavour> {
|
||||
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
|
||||
pub fn read(mime: &str) -> Option<Flavour> {
|
||||
autoreleasepool(|_| {
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
TYPES.into_iter().find_map(|(t, mime)| {
|
||||
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
|
||||
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
})
|
||||
@@ -414,7 +431,7 @@ mod mac {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -448,6 +465,36 @@ mod tests {
|
||||
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
|
||||
}
|
||||
|
||||
/// What another app copied as vector art is asked for under the names the platforms give it,
|
||||
/// and read as the flavour the paste imports (#1499).
|
||||
#[test]
|
||||
fn vector_art_is_read_under_each_platforms_names() {
|
||||
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
|
||||
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
|
||||
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
|
||||
assert_eq!(mac(SVG), ["public.svg-image"]);
|
||||
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
|
||||
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
|
||||
for asked in PASTE_ORDER {
|
||||
// Each type's data is the flavour asked for (a bitmap its own type): what
|
||||
// `clipboard.import*` then reads. Text and metafiles come another way.
|
||||
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
|
||||
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
|
||||
}
|
||||
// Vector art before text, text before bitmaps.
|
||||
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
|
||||
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
|
||||
}
|
||||
|
||||
/// An .eps file copied in a file manager pastes as EPS.
|
||||
#[test]
|
||||
fn a_copied_eps_file_pastes() {
|
||||
let eps = dir("eps").join("equation.eps");
|
||||
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
|
||||
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
|
||||
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crlf_left_by_the_uri_list_is_ignored() {
|
||||
let red = dir("crlf").join("red.png");
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The GPU the Wayland compositor draws with ([`crate::gpu`] ranks it first under Automatic).
|
||||
//!
|
||||
//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor
|
||||
//! may not show them: it can refuse them and end the window's connection (#502), or take them and
|
||||
//! show nothing, with no error on either side, so neither the restart on a failed adapter nor
|
||||
//! the first-frame watch notices. The second happened on a hybrid laptop whose Hyprland runs on
|
||||
//! the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel one: the panel
|
||||
//! rule picked Intel and the window stayed empty. Which GPU drives a display is a guess at the
|
||||
//! compositor's GPU; the compositor states it, as the `main_device` of its linux-dmabuf feedback
|
||||
//! (protocol version 4): the device it imports client buffers on.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
#[cfg(target_os = "linux")]
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
#[cfg(target_os = "linux")]
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
#[cfg(target_os = "linux")]
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1};
|
||||
#[cfg(target_os = "linux")]
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1};
|
||||
|
||||
/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a
|
||||
/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its
|
||||
/// device isn't a PCI GPU, and on other systems.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
|
||||
let device = match main_device() {
|
||||
Ok(device) => device,
|
||||
Err(why) => {
|
||||
log::debug!("the compositor's GPU is unknown: {why}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device);
|
||||
match pci {
|
||||
Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"),
|
||||
None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)),
|
||||
}
|
||||
pci
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of
|
||||
/// its own that is closed again before the window opens.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main_device() -> Result<u64, String> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?;
|
||||
let (globals, mut queue) = registry_queue_init::<Feedback>(&conn).map_err(|e| e.to_string())?;
|
||||
let qh = queue.handle();
|
||||
// Version 4 brought the feedback; older compositors only list formats.
|
||||
let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?;
|
||||
let _feedback = dmabuf.get_default_feedback(&qh, ());
|
||||
let mut state = Feedback::default();
|
||||
// The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps
|
||||
// a compositor that never ends it from holding up the start.
|
||||
for _ in 0..4 {
|
||||
if state.done {
|
||||
break;
|
||||
}
|
||||
queue.roundtrip(&mut state).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Dropping the connection releases the objects.
|
||||
let device = state.main_device.ok_or("the compositor named no main device")?;
|
||||
// A `dev_t`, in this machine's byte order.
|
||||
let device: [u8; 8] = device.try_into().map_err(|d: Vec<u8>| format!("a {}-byte device number", d.len()))?;
|
||||
Ok(u64::from_ne_bytes(device))
|
||||
}
|
||||
|
||||
/// What [`main_device`] collects from the feedback events.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct Feedback {
|
||||
main_device: Option<Vec<u8>>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for Feedback {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<ZwpLinuxDmabufV1, ()> for Feedback {
|
||||
// Version 4 sends no format or modifier events: the feedback replaces them.
|
||||
fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for Feedback {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZwpLinuxDmabufFeedbackV1,
|
||||
event: zwp_linux_dmabuf_feedback_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device),
|
||||
zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()`
|
||||
/// split it.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn major_minor(device: u64) -> (u64, u64) {
|
||||
let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32);
|
||||
let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12);
|
||||
(major, minor)
|
||||
}
|
||||
|
||||
/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from
|
||||
/// `<sys_dev_char>/MAJOR:MINOR/device/{vendor,device}`.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> {
|
||||
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
|
||||
let s = std::fs::read_to_string(p).ok()?;
|
||||
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
|
||||
};
|
||||
let (major, minor) = major_minor(device);
|
||||
let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device");
|
||||
Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// glibc's `makedev()`.
|
||||
fn makedev(major: u64, minor: u64) -> u64 {
|
||||
((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_numbers_split_into_major_and_minor() {
|
||||
// DRM nodes: card1 is 226:1, renderD128 is 226:128.
|
||||
assert_eq!(super::major_minor(0xe201), (226, 1));
|
||||
assert_eq!(super::major_minor(0xe280), (226, 128));
|
||||
for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] {
|
||||
assert_eq!(super::major_minor(makedev(major, minor)), (major, minor));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-dev-char-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
|
||||
std::fs::create_dir_all(dir.join("226:128/device"))?;
|
||||
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
|
||||
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
|
||||
// A device that isn't a PCI GPU (a virtual one) has no ids.
|
||||
std::fs::create_dir_all(dir.join("226:0/device"))?;
|
||||
let render = super::pci_ids(&dir, makedev(226, 128));
|
||||
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
|
||||
let missing = super::pci_ids(&dir, makedev(226, 1));
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
assert_eq!(render, Some((0x10de, 0x25ac)));
|
||||
assert_eq!(virtual_gpu, None);
|
||||
assert_eq!(missing, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compositor_names_a_pci_gpu_or_nothing() {
|
||||
// Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU.
|
||||
if let Some((vendor, _)) = super::compositor_gpu() {
|
||||
assert_ne!(vendor, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
-47
@@ -4,8 +4,9 @@
|
||||
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
|
||||
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
|
||||
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
|
||||
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU that drives a
|
||||
//! display and the one that drives the primary display ([`display_gpus`]), the power preference
|
||||
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU the Wayland
|
||||
//! compositor draws with ([`crate::compositor_gpu`]), the GPU that drives a display and the one
|
||||
//! that drives the primary display ([`display_gpus`]), the power preference
|
||||
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
|
||||
//! eframe is handed [`selector`], which takes the first of that order.
|
||||
//!
|
||||
@@ -19,6 +20,10 @@
|
||||
//! matches, the order is the power preference's as before. Power Saving and High Performance,
|
||||
//! chosen by the user, order by kind as they always did.
|
||||
//!
|
||||
//! Under Wayland, the compositor's own GPU comes before the displays: the compositor says which
|
||||
//! GPU it draws with, and frames from another one may never reach the screen, with no error, even
|
||||
//! from the GPU behind the built-in panel when the compositor runs on the discrete one.
|
||||
//!
|
||||
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
|
||||
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
|
||||
//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
|
||||
@@ -26,9 +31,16 @@
|
||||
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
|
||||
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
|
||||
//! adapter, so it ends when none is left.
|
||||
//!
|
||||
//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen,
|
||||
//! the discrete GPU never put the window's first frame on the screen, with no error and no panic,
|
||||
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
|
||||
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
|
||||
//! the app again without that adapter in the same way.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
|
||||
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
|
||||
@@ -50,6 +62,11 @@ const HELP: &str =
|
||||
/// window is something else (such as the compositor restarting).
|
||||
pub const STARTUP_FRAMES: u64 = 10;
|
||||
|
||||
/// How long the window's first frame may take to reach the screen, from the app's creation,
|
||||
/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole
|
||||
/// interface and compiles its shaders, a second at most on a slow machine.
|
||||
pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
|
||||
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
|
||||
/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
|
||||
@@ -81,6 +98,12 @@ pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> V
|
||||
if automatic(pref, env) { display_gpus() } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// The GPU to rank first: the one the Wayland compositor draws with, when the choice is
|
||||
/// [`automatic`].
|
||||
pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) -> Option<(u32, u32)> {
|
||||
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
|
||||
}
|
||||
|
||||
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
|
||||
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
|
||||
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
|
||||
@@ -90,6 +113,13 @@ pub fn backends(default: Backends, env_set: bool) -> Backends {
|
||||
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
|
||||
}
|
||||
|
||||
/// The largest texture side to ask the device for, which bounds the window's surface: the
|
||||
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
|
||||
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
|
||||
pub fn surface_limit(default: u32, adapter: u32) -> u32 {
|
||||
default.max(adapter)
|
||||
}
|
||||
|
||||
/// One adapter, as [`adapter_order`] sees it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
@@ -118,6 +148,11 @@ impl Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it is the GPU the compositor draws with (`compositor`, by PCI ids).
|
||||
fn composites(&self, compositor: Option<(u32, u32)>) -> bool {
|
||||
compositor == Some(self.pci)
|
||||
}
|
||||
|
||||
/// Whether it drives one of `displays`, and whether that is the primary one.
|
||||
fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) {
|
||||
let shown = displays.iter().find(|g| g.pci == self.pci);
|
||||
@@ -254,15 +289,23 @@ fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
|
||||
|
||||
/// The order to try `candidates` in (their indices): only those that present to the window and
|
||||
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
|
||||
/// software; native backends before OpenGL; the GPUs that drive one of `displays`, the primary
|
||||
/// display's first; then by `power`; then DX12 before Vulkan, keeping the system's order among
|
||||
/// equals (all of it for [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's
|
||||
/// software; native backends before OpenGL; the GPU the compositor draws with (`compositor`); the
|
||||
/// GPUs that drive one of `displays`, the primary display's first; then by `power`; then DX12
|
||||
/// before Vulkan, keeping the system's order among equals (all of it for
|
||||
/// [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's
|
||||
/// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545);
|
||||
/// elsewhere there is no DX12 adapter, so the order is unchanged.
|
||||
///
|
||||
/// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no
|
||||
/// ids at all), the order is the power preference's alone, as it was.
|
||||
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: &[DisplayGpu], named: Option<&str>, skip: &[String]) -> Vec<usize> {
|
||||
pub fn adapter_order(
|
||||
candidates: &[Candidate],
|
||||
power: PowerPreference,
|
||||
displays: &[DisplayGpu],
|
||||
compositor: Option<(u32, u32)>,
|
||||
named: Option<&str>,
|
||||
skip: &[String],
|
||||
) -> Vec<usize> {
|
||||
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
|
||||
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
|
||||
// Stable, so equals keep the system's order.
|
||||
@@ -273,6 +316,7 @@ pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays:
|
||||
unnamed,
|
||||
c.device_type == DeviceType::Cpu,
|
||||
c.backend == Backend::Gl,
|
||||
!c.composites(compositor),
|
||||
!drives_display,
|
||||
!drives_primary,
|
||||
power_rank(c.device_type, power),
|
||||
@@ -311,6 +355,8 @@ pub struct Startup {
|
||||
adapter: OnceLock<String>,
|
||||
/// The UI's context, which counts the frames run.
|
||||
ui: OnceLock<egui::Context>,
|
||||
/// When the app was created, the files named at the start opened: the first frame is due.
|
||||
ready: OnceLock<Instant>,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
@@ -319,17 +365,88 @@ impl Startup {
|
||||
// Set once; the window is created once.
|
||||
let _ = self.ui.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The app is ready to draw its first frame.
|
||||
pub fn ready(&self) {
|
||||
// Set once, like `created`.
|
||||
let _ = self.ready.set(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a
|
||||
/// `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart left out. Every adapter, the displays
|
||||
/// and the choice are logged: which GPU draws is the first question in every black-window report.
|
||||
pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
|
||||
/// What [`watch_first_frame`] makes of the window's start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum FirstFrame {
|
||||
/// Not due yet.
|
||||
Wait,
|
||||
/// On the screen (the second frame began, so the first was presented), or the window was
|
||||
/// minimized at the start: nothing to watch.
|
||||
Shown,
|
||||
/// Overdue.
|
||||
Hung,
|
||||
}
|
||||
|
||||
/// The window's start after `frames` frames run, `since` its first frame was due.
|
||||
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
|
||||
if frames >= 2 || minimized {
|
||||
FirstFrame::Shown
|
||||
} else if since >= FIRST_FRAME_TIMEOUT {
|
||||
FirstFrame::Hung
|
||||
} else {
|
||||
FirstFrame::Wait
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the window's first frame from another thread: when it hasn't reached the screen
|
||||
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
|
||||
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
|
||||
/// thread is stuck in the graphics driver.
|
||||
pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let watch = move || loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
let Some(due) = startup.ready.get() else { continue };
|
||||
let ui = startup.ui.get();
|
||||
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
|
||||
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
|
||||
match first_frame(frames, due.elapsed(), minimized) {
|
||||
FirstFrame::Wait => continue,
|
||||
FirstFrame::Shown => return,
|
||||
FirstFrame::Hung => {}
|
||||
}
|
||||
let mut skip = skipped();
|
||||
let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return };
|
||||
log::error!(
|
||||
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
|
||||
FIRST_FRAME_TIMEOUT.as_secs()
|
||||
);
|
||||
skip.push(failed.to_string());
|
||||
match restart(&skip.join(",")) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(e) => {
|
||||
log::error!("starting again failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) {
|
||||
log::warn!("no watch on the window's first frame: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
|
||||
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart
|
||||
/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first
|
||||
/// question in every black-window report.
|
||||
pub fn selector(
|
||||
power: PowerPreference,
|
||||
displays: Vec<DisplayGpu>,
|
||||
compositor: Option<(u32, u32)>,
|
||||
startup: Arc<Startup>,
|
||||
) -> NativeAdapterSelectorMethod {
|
||||
let named = std::env::var(NAME_ENV).ok();
|
||||
let skip = skipped();
|
||||
Arc::new(move |adapters, surface| {
|
||||
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
|
||||
let order = adapter_order(&candidates, power, &displays, named.as_deref(), &skip);
|
||||
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
|
||||
let listed: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -338,6 +455,8 @@ pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<
|
||||
", can't show the window"
|
||||
} else if skip.contains(&c.key) {
|
||||
", failed before"
|
||||
} else if c.composites(compositor) {
|
||||
", the compositor draws with it"
|
||||
} else if drives_primary {
|
||||
", drives the primary display"
|
||||
} else if drives_display {
|
||||
@@ -354,6 +473,7 @@ pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<
|
||||
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
|
||||
};
|
||||
let why = match chosen.drives(&displays) {
|
||||
_ if chosen.composites(compositor) => "the compositor draws with it".to_string(),
|
||||
(_, true) => "it drives the primary display".to_string(),
|
||||
(true, false) => "it drives a display".to_string(),
|
||||
(false, false) if displays.is_empty() => format!("power preference {power:?}"),
|
||||
@@ -434,6 +554,14 @@ mod tests {
|
||||
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true }
|
||||
}
|
||||
|
||||
/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that
|
||||
/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing.
|
||||
#[test]
|
||||
fn the_surface_may_be_as_large_as_the_adapter_allows() {
|
||||
assert_eq!(surface_limit(8192, 16384), 16384);
|
||||
assert_eq!(surface_limit(8192, 4096), 8192);
|
||||
}
|
||||
|
||||
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
|
||||
order.iter().map(|&i| c[i].name.clone()).collect()
|
||||
}
|
||||
@@ -476,14 +604,14 @@ mod tests {
|
||||
fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() {
|
||||
let c = ryzen_desktop();
|
||||
for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
}
|
||||
// Without the display information the order is power saving's, as before.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
// After the NVIDIA adapters failed, the integrated GPU is next.
|
||||
let skip = vec![c[2].key.clone(), c[0].key.clone()];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]);
|
||||
}
|
||||
|
||||
/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the
|
||||
@@ -492,7 +620,7 @@ mod tests {
|
||||
fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() {
|
||||
let c = ryzen_desktop();
|
||||
for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}");
|
||||
}
|
||||
// Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the
|
||||
// system's order among them (Mesa puts the compositor's GPU first), and a GPU without one
|
||||
@@ -504,9 +632,35 @@ mod tests {
|
||||
pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)),
|
||||
];
|
||||
let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)];
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
|
||||
/// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the
|
||||
/// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the
|
||||
/// compositor draws with comes first; without it, the panel's, as before.
|
||||
#[test]
|
||||
fn the_gpu_the_compositor_draws_with_comes_before_the_panels() {
|
||||
const RTX_3050: (u32, u32) = (0x10de, 0x25ac);
|
||||
const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8);
|
||||
let mut c = vec![
|
||||
pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050),
|
||||
pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL),
|
||||
pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)),
|
||||
];
|
||||
c[2].presents = false;
|
||||
let panel = [primary(INTEL_RPL)];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]);
|
||||
// The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]);
|
||||
// `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]);
|
||||
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]);
|
||||
}
|
||||
|
||||
/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so
|
||||
@@ -514,11 +668,11 @@ mod tests {
|
||||
#[test]
|
||||
fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() {
|
||||
let c = ryzen_desktop();
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
// The displays only matter among adapters that can show the window; a GPU driving no
|
||||
// display still loses to one that does.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
}
|
||||
|
||||
/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id,
|
||||
@@ -526,18 +680,21 @@ mod tests {
|
||||
#[test]
|
||||
fn displays_no_adapter_matches_leave_the_power_preference_in_charge() {
|
||||
let c = ryzen_desktop();
|
||||
let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
|
||||
for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}");
|
||||
}
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090");
|
||||
assert_eq!(
|
||||
names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0],
|
||||
"NVIDIA GeForce RTX 3090"
|
||||
);
|
||||
// The #502 machine's adapters report no ids in these tests: the primary display never matches.
|
||||
let c = issue_502();
|
||||
assert_eq!(
|
||||
adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]),
|
||||
adapter_order(&c, PowerPreference::None, &[], None, &[])
|
||||
adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]),
|
||||
adapter_order(&c, PowerPreference::None, &[], None, None, &[])
|
||||
);
|
||||
assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty());
|
||||
assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays.
|
||||
@@ -545,10 +702,10 @@ mod tests {
|
||||
fn a_named_adapter_comes_before_the_one_driving_the_display() {
|
||||
let mut c = ryzen_desktop();
|
||||
let displays = [primary(NVIDIA_3090)];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]);
|
||||
c[2].presents = false;
|
||||
c[0].presents = false;
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]);
|
||||
}
|
||||
|
||||
/// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic
|
||||
@@ -566,6 +723,9 @@ mod tests {
|
||||
assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty());
|
||||
// Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop).
|
||||
assert_eq!(preferred_displays(None, None), display_gpus());
|
||||
assert_eq!(preferred_compositor(Some("lowPower"), None), None);
|
||||
assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None);
|
||||
assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -662,7 +822,7 @@ mod tests {
|
||||
#[test]
|
||||
fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() {
|
||||
let c = issue_502();
|
||||
let order = adapter_order(&c, power_preference(None, None), &[], None, &[]);
|
||||
let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]);
|
||||
let first = &c[order[0]].name;
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
|
||||
@@ -675,7 +835,7 @@ mod tests {
|
||||
#[test]
|
||||
fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() {
|
||||
let c = issue_502();
|
||||
let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[]));
|
||||
let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[]));
|
||||
assert_eq!(
|
||||
order(PowerPreference::None),
|
||||
[
|
||||
@@ -721,20 +881,38 @@ mod tests {
|
||||
};
|
||||
let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)];
|
||||
assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics"));
|
||||
let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
|
||||
assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics");
|
||||
let skip = parse_skip(&c[first[0]].key);
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]);
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]);
|
||||
// A comma in a name can't split the list.
|
||||
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
|
||||
let s = Duration::from_secs;
|
||||
// The second frame began: the first was presented.
|
||||
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
|
||||
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
|
||||
for frames in [0, 1] {
|
||||
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
|
||||
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
|
||||
}
|
||||
// A window minimized at the start may not draw again until restored.
|
||||
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
|
||||
// A hung adapter is left out once, like one that failed.
|
||||
let skip = vec!["Dx12:10de:28a0".to_string()];
|
||||
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
|
||||
assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_tries_the_next_adapter() {
|
||||
let c = issue_502();
|
||||
let mut skip = vec![];
|
||||
let mut tried = vec![];
|
||||
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() {
|
||||
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() {
|
||||
tried.push(c[i].name.clone());
|
||||
skip.push(c[i].key.clone());
|
||||
}
|
||||
@@ -755,36 +933,36 @@ mod tests {
|
||||
c[0].presents = false;
|
||||
c[3].presents = false;
|
||||
for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] {
|
||||
let order = adapter_order(&c, power, &[], None, &[]);
|
||||
let order = adapter_order(&c, power, &[], None, None, &[]);
|
||||
assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}");
|
||||
assert_eq!(order.len(), 2);
|
||||
}
|
||||
for p in &mut c {
|
||||
p.presents = false;
|
||||
}
|
||||
assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty());
|
||||
assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty());
|
||||
assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty());
|
||||
assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() {
|
||||
let c = issue_502();
|
||||
let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]);
|
||||
let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]);
|
||||
assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
|
||||
assert_eq!(order.len(), 4);
|
||||
// Any case; every match comes before the rest, natively first.
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]);
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]);
|
||||
assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]);
|
||||
// An empty name or one nothing matches changes nothing.
|
||||
for named in ["", "no such gpu"] {
|
||||
assert_eq!(
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]),
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], None, &[])
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]),
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[])
|
||||
);
|
||||
}
|
||||
// A named adapter that failed is left out like any other.
|
||||
let skip = vec![c[1].key.clone()];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0);
|
||||
}
|
||||
|
||||
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
|
||||
@@ -798,9 +976,9 @@ mod tests {
|
||||
gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
|
||||
];
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
|
||||
assert_eq!(order, [3, 1, 2, 0, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]);
|
||||
}
|
||||
|
||||
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
|
||||
@@ -815,9 +993,9 @@ mod tests {
|
||||
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
|
||||
];
|
||||
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
|
||||
assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}");
|
||||
assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}");
|
||||
}
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
|
||||
//! every filter but doesn't show their names. The accessory uses the same filters and updates
|
||||
//! the filename, whose extension already determines the format in the save command.
|
||||
//!
|
||||
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
|
||||
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
|
||||
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
struct Ivars {
|
||||
panel: Weak<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
|
||||
#[unsafe(super = NSView)]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = Ivars]
|
||||
#[name = "VectorcraftSaveFormatAccessory"]
|
||||
struct FormatAccessory;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no additional requirements.
|
||||
unsafe impl NSObjectProtocol for FormatAccessory {}
|
||||
|
||||
impl FormatAccessory {
|
||||
// SAFETY: AppKit's target/action sends the popup as the single object argument.
|
||||
#[unsafe(method(chooseFormat:))]
|
||||
fn choose_format(&self, sender: &NSPopUpButton) {
|
||||
let Some(panel) = self.ivars().panel.load() else { return };
|
||||
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
|
||||
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
|
||||
let Some(extension) = extensions.first() else { return };
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
let new = filename(&name, extension, &self.ivars().filters);
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&new));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The suggested file's type, or the request's default (the first filter).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = Path::new(name).extension().and_then(|e| e.to_str());
|
||||
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return name.to_owned();
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
|
||||
{
|
||||
path.with_extension(extension).to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{name}.{extension}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
|
||||
/// without several named file types are left to rfd.
|
||||
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if filters.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Ok(handle) = window.window_handle() else { return };
|
||||
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
|
||||
// SAFETY: winit keeps its content view alive while this window reference is in use.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
|
||||
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
|
||||
for (name, _) in &accessory.ivars().filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
|
||||
}
|
||||
// The filter list comes from the built-in format registry, small enough for NSInteger.
|
||||
if let Ok(index) = isize::try_from(selected) {
|
||||
popup.selectItemAtIndex(index);
|
||||
}
|
||||
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
|
||||
// open. The selector above has exactly the target/action signature expected by NSControl.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
#[test]
|
||||
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
|
||||
for (name, expected) in
|
||||
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
|
||||
{
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! macOS: the window buttons (close, minimize, zoom) centred on the app bar (#968).
|
||||
//!
|
||||
//! AppKit puts the buttons in a standard title bar, shorter than the app bar they sit over, so they
|
||||
//! rode high on it. [`center_buttons`] moves them to the middle of the bar, inset from its left as
|
||||
//! far as from its top, and makes the title bar as tall as the app bar so they stay clickable.
|
||||
//! AppKit lays its title bar out again when the window resizes or leaves full screen, so this runs
|
||||
//! every frame and moves the buttons only when they are out of place.
|
||||
//!
|
||||
//! Reaching the title bar's views from the window's content view has no safe API, hence the scoped
|
||||
//! `unsafe_code` allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask};
|
||||
use objc2_foundation::NSPoint;
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
/// The room the app bar keeps at its left for the buttons in AppKit's own place, before they are
|
||||
/// centred (and in full screen, where AppKit shows them elsewhere): their width and a gap.
|
||||
pub const DEFAULT_INSET: f32 = 78.0;
|
||||
|
||||
/// Differences below this (in points) leave a frame as it is, so a frame in place isn't set again.
|
||||
const SLACK: f64 = 0.25;
|
||||
|
||||
/// Centre the window's buttons on an app bar `bar` egui points tall, at the window's `zoom` factor
|
||||
/// (native points per egui point). Returns the room the app bar keeps clear at its left for them,
|
||||
/// in egui points, or `None` when they aren't over the bar (full screen) or can't be found.
|
||||
pub fn center_buttons(frame: &eframe::Frame, zoom: f32, bar: f32) -> Option<f32> {
|
||||
let RawWindowHandle::AppKit(handle) = frame.window_handle().ok()?.as_raw() else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and eframe hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let window = view.window()?;
|
||||
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
|
||||
return None;
|
||||
}
|
||||
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
|
||||
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
|
||||
let zoom_button = window.standardWindowButton(NSWindowButton::ZoomButton)?;
|
||||
// The buttons sit in the title bar view, inside the title bar container that sets its height.
|
||||
// SAFETY: both views belong to the window, alive while it is; used right away.
|
||||
let container = unsafe { close.superview().and_then(|titlebar| titlebar.superview()) }?;
|
||||
// Another layout (a later macOS) is left as AppKit made it.
|
||||
if AnyObject::class(&container).name() != c"NSTitlebarContainerView" {
|
||||
return None;
|
||||
}
|
||||
let size = close.frame().size;
|
||||
let gap = minimize.frame().origin.x - (close.frame().origin.x + size.width);
|
||||
let height = f64::from(bar * zoom);
|
||||
let margin = (height - size.height) / 2.0;
|
||||
if !(margin.is_finite() && margin > 0.0 && gap.is_finite() && gap >= 0.0 && zoom > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut bounds = container.frame();
|
||||
let top = window.frame().size.height - height;
|
||||
if (bounds.size.height - height).abs() > SLACK || (bounds.origin.y - top).abs() > SLACK {
|
||||
bounds.size.height = height;
|
||||
bounds.origin.y = top;
|
||||
container.setFrame(bounds);
|
||||
}
|
||||
let mut x = margin;
|
||||
for button in [close, minimize, zoom_button] {
|
||||
let at = NSPoint::new(x, (height - button.frame().size.height) / 2.0);
|
||||
let now = button.frame().origin;
|
||||
if (now.x - at.x).abs() > SLACK || (now.y - at.y).abs() > SLACK {
|
||||
button.setFrameOrigin(at);
|
||||
}
|
||||
x += size.width + gap;
|
||||
}
|
||||
// The last button's right edge (`x` steps one gap past it) and the margin again after it.
|
||||
Some(((x - gap + margin) / f64::from(zoom)) as f32)
|
||||
}
|
||||
+409
-79
@@ -6,7 +6,8 @@
|
||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//!
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
|
||||
//! start) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
|
||||
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
|
||||
@@ -21,7 +22,10 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod automation_args;
|
||||
mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
@@ -31,12 +35,17 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
@@ -69,10 +78,18 @@ impl eframe::App for App {
|
||||
}
|
||||
file_access::unconfined(|| self.app.raw_input_hook(raw));
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
{
|
||||
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
|
||||
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
@@ -105,8 +122,16 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
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());
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
if self.app.ui.status == "quit" {
|
||||
@@ -180,7 +205,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
decode_saved_prefs(&bytes)
|
||||
}
|
||||
|
||||
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
|
||||
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
|
||||
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
|
||||
if let Some(layout) = object.get("docking")
|
||||
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
|
||||
{
|
||||
object.remove("docking");
|
||||
}
|
||||
if let Some(hidden) = object.get_mut(hidden_key) {
|
||||
if let Value::Object(entries) = hidden {
|
||||
entries.retain(|panel, location| {
|
||||
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
|
||||
});
|
||||
} else {
|
||||
object.remove(hidden_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(value).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
@@ -211,48 +271,106 @@ fn save_prefs(app: &VectorcraftApp) {
|
||||
/// The window file dialogs belong to.
|
||||
type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
|
||||
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
|
||||
/// shows it over the window and keeps it in front; macOS shows them as before).
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
let d = match parent {
|
||||
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
|
||||
_ => rfd::FileDialog::new(),
|
||||
};
|
||||
/// What rfd's blocking and asynchronous file dialogs are both set up with.
|
||||
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 folder(self, folder: &str) -> Self;
|
||||
fn file_name(self, name: &str) -> Self;
|
||||
}
|
||||
|
||||
macro_rules! dialog_settings {
|
||||
($($dialog:ty),*) => {$(
|
||||
impl DialogSettings for $dialog {
|
||||
fn parent(self, window: &winit::window::Window) -> Self {
|
||||
self.set_parent(window)
|
||||
}
|
||||
fn title(self, title: &str) -> Self {
|
||||
self.set_title(title)
|
||||
}
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, exts)
|
||||
}
|
||||
fn folder(self, folder: &str) -> Self {
|
||||
self.set_directory(folder)
|
||||
}
|
||||
fn file_name(self, name: &str) -> Self {
|
||||
self.set_file_name(name)
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
|
||||
|
||||
/// `d` set up for `request`: its title, file types, folder and suggested name.
|
||||
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
|
||||
let pick = match request {
|
||||
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
|
||||
PickRequest::OpenMany => {
|
||||
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
|
||||
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
|
||||
});
|
||||
}
|
||||
PickRequest::Folder => return d,
|
||||
};
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_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), exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
Some(folder) => d.folder(folder),
|
||||
None => d,
|
||||
};
|
||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
|
||||
/// window and keeps it in front).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
match parent {
|
||||
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
|
||||
None => dialog_for(rfd::FileDialog::new(), request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
|
||||
fn make_folder(request: &PickRequest) {
|
||||
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
make_folder(request);
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
PickRequest::Save(pick) => {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
dialog.save_file().into_iter().collect()
|
||||
}
|
||||
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
|
||||
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
|
||||
};
|
||||
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
|
||||
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
@@ -288,38 +406,106 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
|
||||
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
|
||||
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
|
||||
/// inside the window's event handler, and resizing it delivered window events to that handler
|
||||
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
|
||||
/// from the run loop, outside the handler.
|
||||
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||
/// offers to kill the window as not responding (#592).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.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));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
fn services(parent: Parent) -> Services {
|
||||
/// The paths a dialog shown as a sheet picks, once it closes.
|
||||
#[cfg(target_os = "macos")]
|
||||
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
|
||||
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
|
||||
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
|
||||
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
|
||||
/// returns at once and the window keeps running; a thread of its own waits for the answer.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
use winit::raw_window_handle::HasWindowHandle as _;
|
||||
// rfd runs the panel modally after all unless it has the running app's window to hold the
|
||||
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
|
||||
let mtm = objc2::MainThreadMarker::new();
|
||||
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
|
||||
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
|
||||
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
|
||||
return None;
|
||||
};
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go.
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
PickRequest::Open(_) => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::OpenMany => {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(pick) => {
|
||||
let picked = dialog.save_file();
|
||||
mac_save_format::attach(window, &pick);
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
};
|
||||
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
|
||||
let _ = sheet_tx.send(answer);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The host's services; `ui` is woken when a file dialog answers.
|
||||
fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
|
||||
// ones re-enter the window's event handler (#867).
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
|
||||
// dialogs run the window's events while they are open.
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Native saves stream into the file as they are encoded.
|
||||
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
|
||||
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
|
||||
})),
|
||||
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
|
||||
system_clipboard: Some(clipboard::system_clipboard()),
|
||||
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
|
||||
@@ -391,8 +577,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// First, so every start-up warning is recorded (`logging`).
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
Some(port) => Some(port),
|
||||
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
|
||||
Ok(port) => port,
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Before desktop services, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
// Before the first font scan (the app's start): the fonts font services load (#579).
|
||||
@@ -402,28 +653,6 @@ fn main() -> std::process::ExitCode {
|
||||
mac_fonts::install();
|
||||
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
|
||||
prefs_dir::install_fonts();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
|
||||
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
|
||||
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// The log file lives in the settings directory, next to the preferences; opened after the
|
||||
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
|
||||
// first. Runs without preferences (agents' test runs) log to standard error only, so they
|
||||
@@ -468,8 +697,13 @@ fn main() -> std::process::ExitCode {
|
||||
// every black-window report.
|
||||
#[cfg(feature = "wgpu")]
|
||||
let displays = gpu::preferred_displays(gpu_pref, power_env);
|
||||
// Under Wayland, the GPU the compositor draws with comes even before those (src/compositor_gpu.rs).
|
||||
#[cfg(feature = "wgpu")]
|
||||
if gpu::automatic(gpu_pref, power_env) {
|
||||
let compositor = gpu::preferred_compositor(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
if !chosen_gpu {
|
||||
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
|
||||
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
|
||||
} else {
|
||||
@@ -477,21 +711,26 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
}));
|
||||
} else {
|
||||
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
|
||||
}
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
@@ -501,18 +740,35 @@ fn main() -> std::process::ExitCode {
|
||||
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
|
||||
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone()));
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone()));
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
|
||||
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
|
||||
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
|
||||
create.instance_descriptor.flags =
|
||||
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
|
||||
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
|
||||
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
|
||||
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler =
|
||||
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
|
||||
// The window's surface may be as large as the adapter allows, not eframe's fixed
|
||||
// 8192 px: a window stretched over several monitors, or grown by a display scale
|
||||
// change, otherwise fails to configure its surface and the app closes (#1459).
|
||||
let device = create.device_descriptor.clone();
|
||||
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
|
||||
let mut descriptor = device(adapter);
|
||||
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
|
||||
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
|
||||
descriptor
|
||||
});
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
gpu::watch_panics();
|
||||
#[cfg(feature = "wgpu")]
|
||||
gpu::watch_first_frame(startup.clone());
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
@@ -524,7 +780,7 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
@@ -556,7 +812,15 @@ fn main() -> std::process::ExitCode {
|
||||
log::info!("rendering with {summary} (power preference {power:?})");
|
||||
created.created(&cc.egui_ctx);
|
||||
if !gpu::skipped().is_empty() {
|
||||
app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
|
||||
// A processor chosen in Settings can be the one that can't show the window.
|
||||
let hint = if chosen_gpu {
|
||||
" (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
app.status(format!(
|
||||
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
|
||||
));
|
||||
}
|
||||
app.graphics_adapter = Some(summary);
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
@@ -566,8 +830,7 @@ fn main() -> std::process::ExitCode {
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
@@ -589,6 +852,9 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
}),
|
||||
)
|
||||
@@ -610,6 +876,70 @@ fn main() -> std::process::ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
use serde_json::json;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.ui.toolbar = false;
|
||||
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")];
|
||||
let original = serde_json::to_value(&app.ui).unwrap();
|
||||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||||
assert!(valid_location.is_object());
|
||||
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
|
||||
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
|
||||
let mut raw = original.clone();
|
||||
raw["docking"] = invalid_layout.clone();
|
||||
raw["docking_hidden"] = invalid_hidden.clone();
|
||||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||||
raw["custom_workspaces"][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);
|
||||
assert!(!loaded.toolbar);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(!loaded.docking_hidden.contains_key("layers"));
|
||||
if invalid_hidden.is_object() {
|
||||
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
|
||||
} else {
|
||||
assert!(loaded.docking_hidden.is_empty());
|
||||
}
|
||||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||||
assert_eq!(loaded.custom_workspaces[0].name, "Future");
|
||||
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]);
|
||||
}
|
||||
}
|
||||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
|
||||
assert!(
|
||||
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
|
||||
"unrelated fields retain normal strict parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||||
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "profile.rs"]
|
||||
mod profile;
|
||||
|
||||
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
|
||||
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
|
||||
/// BSD).
|
||||
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
let legacy = name == "DrawCraft" || lower == "drawcraft";
|
||||
profile::prefs_path(legacy)
|
||||
}
|
||||
|
||||
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
|
||||
|
||||
@@ -1,127 +1,54 @@
|
||||
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
|
||||
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
|
||||
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
|
||||
//! the PDF viewer.
|
||||
|
||||
use std::path::PathBuf;
|
||||
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
|
||||
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
|
||||
//! Windows.Data.Pdf is unavailable there.
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
use std::process::Command;
|
||||
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
|
||||
/// The system's printing.
|
||||
pub struct SystemPrint;
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
mod windows7;
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
use windows7 as system;
|
||||
|
||||
pub struct SystemPrint;
|
||||
impl PrintService for SystemPrint {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
system::printers()
|
||||
}
|
||||
|
||||
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let r = system::print(job, &file);
|
||||
let result = system::print(job);
|
||||
let to = job.printer.unwrap_or("the default printer");
|
||||
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
|
||||
result.map(|note| format!("Sent “{}” to {to}{note}", job.title))
|
||||
}
|
||||
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
|
||||
Some(std::sync::Arc::new(|job| SystemPrint.print(job)))
|
||||
}
|
||||
|
||||
fn has_setup(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
|
||||
system::setup(printer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_names(text: &str) -> Vec<String> {
|
||||
let mut names: Vec<String> = vec![];
|
||||
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
|
||||
if !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
|
||||
/// default destination").
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_default(text: &str) -> Option<String> {
|
||||
let line = text.lines().find(|l| l.contains(':'))?;
|
||||
let name = line.rsplit_once(':')?.1.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
#[cfg(any(test, windows))]
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
#[cfg(any(test, windows))]
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
|
||||
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
|
||||
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
|
||||
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
|
||||
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
let mut c = Command::new("lp");
|
||||
if let Some(p) = job.printer {
|
||||
c.args(["-d", p]);
|
||||
}
|
||||
c.args(["-t", job.title]).arg(file);
|
||||
let r = output(&mut c);
|
||||
// `lp` has spooled its own copy (or failed).
|
||||
let _ = std::fs::remove_file(file);
|
||||
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
|
||||
pub fn print(job: &PrintJob) -> Result<String, String> {
|
||||
// Copies, layout, marks and colour management are already in the PDF. Preserve its
|
||||
// physical sheet size and the queue's duplex default without printing copies twice.
|
||||
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
|
||||
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn setup(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = printer;
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = printer;
|
||||
@@ -131,92 +58,13 @@ mod system {
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
if Command::new("system-config-printer").spawn().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// CUPS' own pages.
|
||||
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
|
||||
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new("powershell");
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let mut command = {
|
||||
let mut command = Command::new("explorer");
|
||||
command.arg("ms-settings:printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lpstat_lists_names_and_the_default() {
|
||||
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
|
||||
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
|
||||
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
|
||||
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
|
||||
assert_eq!(lpstat_default("no system default destination\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_lines_become_printers() {
|
||||
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
|
||||
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use vectorcraft_ui_egui::print::{PrintJob, Printer};
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
|
||||
// truncate an existing path, and remove incomplete files or refused launches immediately.
|
||||
use std::io::Write as _;
|
||||
let mut file =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
|
||||
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(format!("can't write {}: {e}", path.display()));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new(
|
||||
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe"),
|
||||
);
|
||||
c.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let result = submit(job, &file);
|
||||
if result.is_err() {
|
||||
// No viewer accepted the launch, so no asynchronous reader needs this document.
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn powershell_lines_and_quotes_are_preserved() {
|
||||
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
|
||||
}
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf_for_the_async_viewer() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
"HOME" => Some("/user".into()),
|
||||
"APPDATA" => Some("/roaming".into()),
|
||||
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
|
||||
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
|
||||
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
|
||||
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
|
||||
//! never keeps the app from starting.
|
||||
//!
|
||||
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
|
||||
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
|
||||
const X11_LIBS: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
|
||||
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
|
||||
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
|
||||
/// and an X display to open.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
|
||||
}
|
||||
|
||||
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
|
||||
/// count as missing.
|
||||
pub fn x11_libraries_found() -> bool {
|
||||
let cache = read_ldconfig();
|
||||
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
|
||||
X11_LIBS
|
||||
.iter()
|
||||
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
|
||||
/// real listing starts with "N libs found in cache".
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
|
||||
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-linux-gnu"
|
||||
} else if cfg!(target_arch = "riscv64") {
|
||||
"riscv64-linux-gnu"
|
||||
} else {
|
||||
"x86_64-linux-gnu"
|
||||
};
|
||||
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
|
||||
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11"), &wayland));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("automatic"), &wayland));
|
||||
assert!(!wanted(None, &wayland));
|
||||
assert!(!wanted(Some("wayland"), &wayland));
|
||||
// An X11 session already opens on X11; without an X display there is no XWayland to use.
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
|
||||
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
|
||||
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
|
||||
assert!(!ldconfig_has(cache, "libX11.so", None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Invalid roots must exit before desktop initialization on every platform, including a CI
|
||||
/// machine with no display. A regression must fail rather than hang with a window open.
|
||||
fn rejects(args: &[&str], message: &str) {
|
||||
rejects_with_env(args, &[], message);
|
||||
}
|
||||
|
||||
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let timed_out = loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break false;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
break true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(!timed_out, "{args:?}: desktop started or did not exit promptly");
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}: {:?}", out.stdout);
|
||||
// Exact stderr excludes unrelated startup warnings or display errors that could otherwise
|
||||
// make a malformed invocation fail for an unrelated reason.
|
||||
assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("vectorcraft: {message}\n"), "{args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_empty_or_option_operands_fail_before_startup() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
rejects(&["--control", "7979", flag], &format!("{flag} needs a folder"));
|
||||
rejects(&[flag], &format!("{flag} needs a folder"));
|
||||
rejects(&["--control", "7979", &format!("{flag}=")], &format!("{flag} needs a non-empty folder"));
|
||||
rejects(&[flag, ""], &format!("{flag} needs a non-empty folder"));
|
||||
for next in ["--version", "--control", "--automation-write-root", "-folder"] {
|
||||
rejects(&[flag, next], &format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_roots_fail_for_both_spellings() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
|
||||
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_does_not_consume_version() {
|
||||
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
}
|
||||
|
||||
/// `--control` without a port no longer starts the app without a control server, and the
|
||||
/// argument after it isn't reinterpreted (#1373).
|
||||
#[test]
|
||||
fn control_without_a_valid_port_fails_before_startup() {
|
||||
let need = "--control needs a port number from 1 to 65535";
|
||||
rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`"));
|
||||
rejects(&["--control"], need);
|
||||
rejects(&["--control", "abc"], &format!("{need}, not `abc`"));
|
||||
rejects(&["--control=0"], &format!("{need}, not `0`"));
|
||||
rejects(&["--control="], &format!("{need}, not ``"));
|
||||
rejects(&["--control", "7979", "--control", "7980"], "--control given twice");
|
||||
rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_wins_over_a_bad_control_port_variable() {
|
||||
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
@@ -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="butt" stroke-linejoin="miter"><path d="M12 3V21M3 12H21" stroke-width="1.5" /><rect x="6.5" y="6.5" width="11" height="11" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -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"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor" stroke-width="1"/></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"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="none"/></svg>
|
||||
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 265 B |
@@ -17,14 +17,16 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.callmeBalloch]
|
||||
real_name = "Riccardo Balloch"
|
||||
sync_github_name = false
|
||||
[people.crqch]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mo0kid]
|
||||
sync_github_name = true
|
||||
@@ -55,3 +57,39 @@ sync_github_name = false
|
||||
real_name = "Fabio William Conceição"
|
||||
display_name = "Messhias"
|
||||
sync_github_name = false
|
||||
|
||||
[people.KattKnight]
|
||||
display_name = "KattKnight (ayla.dsc)"
|
||||
sync_github_name = false
|
||||
|
||||
[people.NoJuanNobody]
|
||||
real_name = "Alejandro Londono"
|
||||
display_name = "alex"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.atoslins]
|
||||
real_name = "Atos Lins"
|
||||
display_name = "Atos Lins"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.aguo890]
|
||||
real_name = "Aaron Guo"
|
||||
display_name = "Aaron Guo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
@@ -14,7 +14,7 @@ Affinity files.
|
||||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
|
||||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
|
||||
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
|
||||
| Vector masks (`AdCh` curves and shapes) | clip group |
|
||||
@@ -33,7 +33,7 @@ Everything else is reported in the import warnings, one line per kind with a cou
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round, stars with rounded points, several fills or strokes on one object (the
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
|
||||
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
// its own radius and type, clockwise from the top left.
|
||||
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
|
||||
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
|
||||
let i = if locked { 0 } else { i };
|
||||
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
|
||||
});
|
||||
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
} else {
|
||||
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
|
||||
r.warn("rectangles with concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
cornered_rect(
|
||||
b,
|
||||
corners.map(|(f, t)| {
|
||||
let (crx, cry) = if !absolute {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
} else if sx > 0.0 && sy > 0.0 {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
(f / sx, f / sy)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
(crx, cry, t == Some(1))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
@@ -165,22 +181,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
|
||||
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
|
||||
/// one side shrink in proportion until they do.
|
||||
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
|
||||
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
|
||||
let [tl, tr, br, bl] = corners;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
|
||||
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
|
||||
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
|
||||
// (where the corner leaves the previous side, its two handles, where it joins the next side)
|
||||
let arcs = [
|
||||
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
|
||||
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
|
||||
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
|
||||
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
|
||||
];
|
||||
let mut segments = Vec::with_capacity(8);
|
||||
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
|
||||
segments.push([s, s, s]);
|
||||
if s != e {
|
||||
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
|
||||
}
|
||||
}
|
||||
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
|
||||
@@ -166,3 +166,39 @@ fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
|
||||
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
|
||||
fn corner_types(ids: [u16; 4]) -> F {
|
||||
let mut raw = vec![0xaa];
|
||||
raw.extend(tag(b"CTyp").0.to_le_bytes());
|
||||
raw.extend(4u32.to_le_bytes());
|
||||
raw.extend(0u16.to_le_bytes());
|
||||
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
|
||||
F::Raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
|
||||
let p = |x: f64, y: f64| Point { x, y };
|
||||
// 10×20 box: relative radii scale by 10. Round 5, round 1, straight 3, none.
|
||||
let corners = |lock: Option<bool>| {
|
||||
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
|
||||
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
|
||||
read(vec![shape(100, b"ShNR", fields)], 100)
|
||||
};
|
||||
|
||||
let unlocked = corners(Some(false));
|
||||
assert!(unlocked.warnings.is_empty(), "{:?}", unlocked.warnings);
|
||||
let sub = &path(&unlocked).subpaths[0];
|
||||
assert_eq!(sub.start, p(5.0, 0.0));
|
||||
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
|
||||
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
|
||||
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
|
||||
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
|
||||
|
||||
// Locked (the default): every corner follows the top left one.
|
||||
let locked = corners(None);
|
||||
let sub = &path(&locked).subpaths[0];
|
||||
assert_eq!(sub.segments.len(), 8);
|
||||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
@@ -20,7 +20,7 @@ patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2,
|
||||
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
|
||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
|
||||
|
||||
@@ -115,3 +115,31 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
let (visible, paint) = match it {
|
||||
AppearanceItem::Fill(f) => (f.visible, &f.paint),
|
||||
AppearanceItem::Stroke(s) => (s.visible, &s.paint),
|
||||
};
|
||||
if let (true, Some(c)) = (visible, 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);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for n in nodes {
|
||||
walk(n, &mut seen);
|
||||
}
|
||||
let mut v: Vec<(usize, usize, Color)> = seen.into_values().collect();
|
||||
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
v.into_iter().map(|(.., c)| c).collect()
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn group(children: Vec<Node>) -> Node {
|
||||
fn compound(paths: Vec<PathData>, c: Color) -> Node {
|
||||
let children = paths.into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
|
||||
n.appearance = fill(c);
|
||||
n.appearance = fill(c).into();
|
||||
n
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(crate) fn stitch_tile() -> Node {
|
||||
let dash = path(shapes::rounded_rectangle(Rect::new(1.5, -0.75, 8.5, 0.75), 0.75), Color::BLACK);
|
||||
// Invisible spacer that fixes the tile width.
|
||||
let mut spacer = path(shapes::rectangle(Rect::new(0.0, -0.75, 10.0, 0.75)), Color::BLACK);
|
||||
spacer.appearance = Appearance::default();
|
||||
spacer.appearance = Appearance::default().into();
|
||||
group(vec![spacer, dash])
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ pub fn defaults() -> &'static [Brush] {
|
||||
rotation: (-180.0, 180.0),
|
||||
rotation_relative_to_path: false,
|
||||
colorization: Colorization::Tints,
|
||||
modes: None,
|
||||
}),
|
||||
},
|
||||
Brush {
|
||||
@@ -184,6 +185,7 @@ pub fn defaults() -> &'static [Brush] {
|
||||
rotation: (-35.0, 35.0),
|
||||
rotation_relative_to_path: true,
|
||||
colorization: Colorization::None,
|
||||
modes: None,
|
||||
}),
|
||||
},
|
||||
Brush {
|
||||
|
||||
+167
-10
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
|
||||
pub use colorize::{colorize, instance_art, stain, tint_node};
|
||||
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
|
||||
pub use defaults::defaults;
|
||||
pub use serde_json;
|
||||
|
||||
@@ -71,6 +71,15 @@ impl BrushKind {
|
||||
BrushKind::Bristle(_) => "bristle",
|
||||
}
|
||||
}
|
||||
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
|
||||
pub fn colorization(&self) -> Colorization {
|
||||
match self {
|
||||
BrushKind::Art(a) => a.colorization,
|
||||
BrushKind::Scatter(s) => s.colorization,
|
||||
BrushKind::Pattern(p) => p.colorization,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How brush art takes the stroke colour.
|
||||
@@ -88,6 +97,29 @@ pub enum Colorization {
|
||||
HueShift { key: vectorcraft_color::Color },
|
||||
}
|
||||
|
||||
impl Colorization {
|
||||
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
|
||||
/// The name brush definitions use (`method`).
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Colorization::None => "none",
|
||||
Colorization::Tints => "tints",
|
||||
Colorization::TintsAndShades => "tintsAndShades",
|
||||
Colorization::HueShift { .. } => "hueShift",
|
||||
}
|
||||
}
|
||||
/// The method named `id` (`key`: the key colour of Hue Shift).
|
||||
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
|
||||
Some(match id {
|
||||
"none" => Colorization::None,
|
||||
"tints" => Colorization::Tints,
|
||||
"tintsAndShades" => Colorization::TintsAndShades,
|
||||
"hueShift" => Colorization::HueShift { key },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
|
||||
/// (the stroke weight scales the brush).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -181,6 +213,12 @@ pub struct Scatter {
|
||||
/// Rotation relative to the path direction (else to the page).
|
||||
pub rotation_relative_to_path: bool,
|
||||
pub colorization: Colorization,
|
||||
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
|
||||
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
|
||||
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
|
||||
/// the two differ, else Fixed — see [`Scatter::modes`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<[Variation; 4]>,
|
||||
}
|
||||
|
||||
impl Default for Scatter {
|
||||
@@ -193,10 +231,25 @@ impl Default for Scatter {
|
||||
rotation: (0.0, 0.0),
|
||||
rotation_relative_to_path: false,
|
||||
colorization: Colorization::None,
|
||||
modes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scatter {
|
||||
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
|
||||
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
|
||||
|
||||
/// Size, spacing, scatter and rotation, each as its (first, second) values.
|
||||
pub fn values(&self) -> [(f64, f64); 4] {
|
||||
[self.size, self.spacing, self.scatter, self.rotation]
|
||||
}
|
||||
/// How size, spacing, scatter and rotation vary.
|
||||
pub fn modes(&self) -> [Variation; 4] {
|
||||
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction the art runs in the brush definition.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -236,6 +289,11 @@ pub struct ArtBrush {
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl ArtBrush {
|
||||
/// The range of the width (%).
|
||||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
|
||||
}
|
||||
|
||||
impl Default for ArtBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -286,6 +344,12 @@ pub struct PatternBrush {
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl PatternBrush {
|
||||
/// The ranges of the scale and of the spacing (%).
|
||||
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
|
||||
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
|
||||
}
|
||||
|
||||
impl Default for PatternBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -340,12 +404,104 @@ pub struct Bristle {
|
||||
pub stiffness: f64,
|
||||
}
|
||||
|
||||
impl Bristle {
|
||||
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
|
||||
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);
|
||||
}
|
||||
|
||||
impl Default for Bristle {
|
||||
fn default() -> Self {
|
||||
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validation ----------
|
||||
|
||||
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
|
||||
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
|
||||
}
|
||||
|
||||
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
|
||||
(within(a, range, fallback), within(b, range, fallback))
|
||||
}
|
||||
|
||||
impl Colorization {
|
||||
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
|
||||
fn sanitize(&mut self) {
|
||||
if let Colorization::HueShift { key } = self
|
||||
&& !key.to_rgb().iter().all(|c| c.is_finite())
|
||||
{
|
||||
*key = vectorcraft_color::Color::BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brush {
|
||||
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
|
||||
pub fn art(&self) -> Vec<&Node> {
|
||||
match &self.kind {
|
||||
BrushKind::Scatter(s) => vec![&s.art],
|
||||
BrushKind::Art(a) => vec![&a.art],
|
||||
BrushKind::Pattern(p) => {
|
||||
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
match &mut self.kind {
|
||||
BrushKind::Calligraphic(c) => {
|
||||
let d = Calligraphic::default();
|
||||
let [angle, roundness, size] = Calligraphic::RANGES;
|
||||
c.angle = within(c.angle, angle, d.angle);
|
||||
c.roundness = within(c.roundness, roundness, d.roundness);
|
||||
c.size = within(c.size, size, d.size);
|
||||
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
|
||||
*v = within(*v, (0.0, max), 0.0);
|
||||
}
|
||||
}
|
||||
BrushKind::Scatter(s) => {
|
||||
let d = Scatter::default();
|
||||
let [size, spacing, scatter, rotation] = Scatter::RANGES;
|
||||
s.size = within_pair(s.size, size, d.size.0);
|
||||
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
|
||||
s.scatter = within_pair(s.scatter, scatter, 0.0);
|
||||
s.rotation = within_pair(s.rotation, rotation, 0.0);
|
||||
s.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Art(a) => {
|
||||
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
|
||||
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
|
||||
*start = within(*start, (0.0, 1.0), 0.0);
|
||||
*end = within(*end, (0.0, 1.0), 1.0);
|
||||
}
|
||||
a.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Pattern(p) => {
|
||||
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
|
||||
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
|
||||
p.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Bristle(b) => {
|
||||
let d = Bristle::default();
|
||||
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
|
||||
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
|
||||
for (v, fallback) in
|
||||
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
|
||||
{
|
||||
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- library ----------
|
||||
|
||||
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
|
||||
@@ -356,9 +512,15 @@ pub fn library(doc: &Document) -> Vec<Brush> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored library value (malformed entries are skipped).
|
||||
/// Parse a stored library value (malformed entries are skipped, values kept within their
|
||||
/// ranges — see [`Brush::sanitize`]).
|
||||
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
|
||||
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()
|
||||
}
|
||||
|
||||
/// Store `lib` as the document's brush library.
|
||||
@@ -404,15 +566,10 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
|
||||
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
|
||||
};
|
||||
let colorization = match &brush.kind {
|
||||
BrushKind::Art(a) => a.colorization,
|
||||
BrushKind::Scatter(s) => s.colorization,
|
||||
BrushKind::Pattern(p) => p.colorization,
|
||||
_ => Colorization::None,
|
||||
};
|
||||
let colorization = brush.kind.colorization();
|
||||
for n in &mut out {
|
||||
colorize(n, colorization, &stroke.paint);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ fn art_brush_arc_length_mapping_is_linear() {
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(5.0, -1.0, 6.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let frame = rect_art(10.0, 2.0);
|
||||
let mut spacer = frame.clone();
|
||||
spacer.appearance = Appearance::default();
|
||||
spacer.appearance = Appearance::default().into();
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
|
||||
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let g = &out[0];
|
||||
@@ -447,3 +447,77 @@ fn junk_pressure_brushes_draw_finite_art() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
|
||||
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
|
||||
let s = scatter_brush(100.0);
|
||||
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
|
||||
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
|
||||
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
|
||||
}
|
||||
|
||||
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
|
||||
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
|
||||
#[test]
|
||||
fn scatter_values_follow_their_modes() {
|
||||
let bp = line(0.0, 0.0, 400.0, 0.0);
|
||||
let heights =
|
||||
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
|
||||
// Fixed: every copy at 50 %, whatever the second value.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
|
||||
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
|
||||
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
|
||||
let (first, last) = (h[0], h[h.len() - 1]);
|
||||
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
|
||||
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
|
||||
// Without recorded pressure, half way between the two.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
|
||||
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
|
||||
let old = sized_scatter(50.0, 300.0, Variation::Random);
|
||||
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
|
||||
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
|
||||
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
|
||||
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
|
||||
}
|
||||
|
||||
/// Definitions from files and commands are kept within their ranges.
|
||||
#[test]
|
||||
fn sanitize_keeps_values_within_their_ranges() {
|
||||
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
|
||||
v["rotation"] = serde_json::json!([720.0, -1e300]);
|
||||
v["scatter"] = serde_json::json!([5000.0, 0.0]);
|
||||
let lib = parse_library(&serde_json::json!([
|
||||
v,
|
||||
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
|
||||
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
|
||||
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
|
||||
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
|
||||
]));
|
||||
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
|
||||
let BrushKind::Scatter(s) = &s.kind else { panic!() };
|
||||
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
|
||||
let BrushKind::Art(a) = &a.kind else { panic!() };
|
||||
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
|
||||
let BrushKind::Pattern(p) = &p.kind else { panic!() };
|
||||
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
|
||||
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
|
||||
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
|
||||
let BrushKind::Bristle(b) = &b.kind else { panic!() };
|
||||
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
|
||||
}
|
||||
|
||||
/// Hue Shift's key colour defaults to the art's most used colour.
|
||||
#[test]
|
||||
fn art_colors_list_the_most_used_first() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let path =
|
||||
|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]);
|
||||
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());
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
|
||||
|
||||
use crate::track::{Rng, Track, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
|
||||
/// Longest mapped segment (points in document space).
|
||||
const MAX_SEG: f64 = 1.5;
|
||||
@@ -188,13 +188,28 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
|
||||
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
|
||||
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
|
||||
/// draws, so a copy's random values stay the same whatever the other modes.
|
||||
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
|
||||
let r = rng.range(a, b);
|
||||
match mode {
|
||||
Variation::Fixed => a,
|
||||
Variation::Random => r,
|
||||
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
|
||||
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
|
||||
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
// The pen pressure at arc length `at` (half way without a recorded one).
|
||||
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
|
||||
let mut s: Option<f64> = None;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
@@ -202,8 +217,10 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
|
||||
if guard > 20000 {
|
||||
break;
|
||||
}
|
||||
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
|
||||
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
|
||||
// Size and spacing place the next copy, so they take the pressure at the last one.
|
||||
let p = pressure_at(s.unwrap_or(0.0));
|
||||
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
|
||||
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
|
||||
let step = aw * size * spacing;
|
||||
let pos = match s {
|
||||
None => step / 2.0,
|
||||
@@ -213,8 +230,9 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
|
||||
break;
|
||||
}
|
||||
s = Some(pos);
|
||||
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
|
||||
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
|
||||
let p = pressure_at(pos);
|
||||
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
|
||||
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
|
||||
let (p, tg) = t.frame(pos);
|
||||
let place = p + crate::track::normal(tg) * off;
|
||||
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.
|
||||
|
||||
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
const NAMES: [&str; 25] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
"US survey inches",
|
||||
"US survey yards",
|
||||
"US survey miles",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
// The US survey foot: exactly 1200/3937 m.
|
||||
let survey_ft = m * 1200.0 / 3937.0;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
21 => survey_ft,
|
||||
22 => survey_ft / 12.0,
|
||||
23 => survey_ft * 3.0,
|
||||
24 => survey_ft * 5280.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -474,7 +479,7 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
|
||||
c.appearance = appearance.clone();
|
||||
c.appearance = appearance.clone().into();
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
@@ -482,7 +487,7 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
} else {
|
||||
Node::new(self.doc.alloc_id(), kind(path))
|
||||
};
|
||||
n.appearance = appearance;
|
||||
n.appearance = appearance.into();
|
||||
Some(n)
|
||||
}
|
||||
|
||||
|
||||
@@ -473,7 +473,14 @@ fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
curve::nurbs(degree, &knots, &ctrl, weights)
|
||||
.map(|mut path| {
|
||||
if flags & 1 != 0 {
|
||||
path.close_path();
|
||||
}
|
||||
path
|
||||
})
|
||||
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
|
||||
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
|
||||
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
|
||||
for (code, name, pt) in [
|
||||
("21", "US survey feet", survey_ft),
|
||||
("22", "US survey inches", survey_ft / 12.0),
|
||||
("23", "US survey yards", survey_ft * 3.0),
|
||||
("24", "US survey miles", survey_ft * 5280.0),
|
||||
] {
|
||||
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
|
||||
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
|
||||
}
|
||||
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
@@ -256,6 +270,53 @@ fn nurbs_splines_stay_within_a_hundredth() {
|
||||
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_point_splines_honor_closed_flags_without_changing_their_curves() {
|
||||
for returns in [false, true] {
|
||||
for rational in [false, true] {
|
||||
let mut original_curves = None;
|
||||
for closed in [false, true] {
|
||||
let flags = 8 | if rational { 4 } else { 0 } | i32::from(closed);
|
||||
let mut e = vec![(0, "SPLINE".to_string()), (70, flags.to_string()), (71, "3".into()), (72, "11".into()), (73, "7".into())];
|
||||
for k in [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2] {
|
||||
e.push((40, k.to_string()));
|
||||
}
|
||||
let end = if returns { (20.0, 20.0) } else { (50.0, 25.0) };
|
||||
for ((x, y), weight) in [(20.0, 20.0), (80.0, 0.0), (80.0, 80.0), (20.0, 80.0), (0.0, 80.0), (0.0, 20.0), end]
|
||||
.into_iter()
|
||||
.zip([1.0, 0.7, 0.7, 1.0, 0.9, 0.9, 1.0])
|
||||
{
|
||||
e.extend([(10, x.to_string()), (20, y.to_string()), (30, "0".into())]);
|
||||
if rational {
|
||||
e.push((41, weight.to_string()));
|
||||
}
|
||||
}
|
||||
let e: Vec<_> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [n] = &leaves(&doc)[..] else { panic!("one spline") };
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert_eq!(path.subpaths[0].closed, closed, "flags {flags}, returns {returns}");
|
||||
let curves: Vec<_> = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).copied().collect();
|
||||
assert!(!curves.is_empty());
|
||||
if let Some(original) = &original_curves {
|
||||
assert_eq!(&curves, original, "closure preserves the rational/non-rational curves");
|
||||
} else {
|
||||
original_curves = Some(curves);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for flags in [8, 9] {
|
||||
let flags_text = flags.to_string();
|
||||
let e =
|
||||
[(0, "SPLINE"), (70, flags_text.as_str()), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [n] = &leaves(&doc)[..] else { panic!("one fit-point spline") };
|
||||
assert_eq!(n.path_data().unwrap().subpaths[0].closed, flags & 1 != 0, "fit-point fallback stays closed/open");
|
||||
}
|
||||
}
|
||||
|
||||
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
|
||||
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
|
||||
vec![
|
||||
|
||||
@@ -349,7 +349,7 @@ impl<'a> Scene<'a> {
|
||||
if n.blend != vectorcraft_color::BlendMode::Normal {
|
||||
self.warn("blending modes are left out");
|
||||
}
|
||||
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
self.warn("opacity masks are left out");
|
||||
}
|
||||
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
|
||||
@@ -652,8 +652,10 @@ impl<'a> Scene<'a> {
|
||||
let h = self.handle();
|
||||
self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline");
|
||||
self.e.normal();
|
||||
// Planar.
|
||||
self.e.int(70, 8);
|
||||
// Planar (bit 3) and, if the subpath is closed, also closed (bit 0). A geometrically
|
||||
// closed cut needs the closed flag set so CAM/CAM tools (e.g. VCarve) treat it as a
|
||||
// pocket/profile rather than asking the operator to join endpoints. (#1299)
|
||||
self.e.int(70, if sub.closed { 8 | 1 } else { 8 });
|
||||
self.e.int(71, 3);
|
||||
self.e.int(72, knots.len() as i64);
|
||||
self.e.int(73, ctrl.len() as i64);
|
||||
|
||||
+56
-1
@@ -155,6 +155,7 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
// 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]);
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
@@ -174,6 +175,60 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
let make_doc = |keep_closure: bool| {
|
||||
doc_with(|d| {
|
||||
cases
|
||||
.iter()
|
||||
.map(|&(closed, returns)| {
|
||||
let start = Point::new(20.0, 20.0);
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to(start);
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
if keep_closure {
|
||||
b.close_path();
|
||||
} else if !returns {
|
||||
// The open reference has the same final straight segment.
|
||||
b.line_to(start);
|
||||
}
|
||||
}
|
||||
path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 0.0)), 2.0)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let closed = make_doc(true);
|
||||
let open = make_doc(false);
|
||||
for version in DxfVersion::ALL {
|
||||
let out = export(&closed, &DxfOptions { version, ..opts(&closed) }).unwrap();
|
||||
let actual = entities(&out);
|
||||
if version == DxfVersion::R12 {
|
||||
let polys: Vec<_> = actual.iter().filter(|e| kind(e) == "POLYLINE").collect();
|
||||
assert_eq!(polys.len(), cases.len());
|
||||
for (poly, &(closed, _)) in polys.iter().zip(&cases) {
|
||||
assert_eq!(get(poly, 70), Some(if closed { "1" } else { "0" }));
|
||||
}
|
||||
assert!(!actual.iter().any(|e| kind(e) == "SPLINE"));
|
||||
continue;
|
||||
}
|
||||
let reference = entities(&export(&open, &DxfOptions { version, ..opts(&open) }).unwrap());
|
||||
assert_eq!(actual.len(), cases.len());
|
||||
for ((spline, reference), &(closed, _)) in actual.iter().zip(&reference).zip(&cases) {
|
||||
assert_eq!(kind(spline), "SPLINE");
|
||||
assert_eq!(get(spline, 70), Some(if closed { "9" } else { "8" }), "{version:?}");
|
||||
let mut normalized = spline.clone();
|
||||
normalized.iter_mut().filter(|p| p.0 == 70).for_each(|p| p.1 = "8".into());
|
||||
assert_eq!(&normalized, reference, "all other entity properties, knots and controls: {version:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_map_to_the_index_palette_or_true_colour() {
|
||||
let d = shapes_doc();
|
||||
@@ -354,7 +409,7 @@ fn images_link_png_or_jpeg_files() {
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))]
|
||||
});
|
||||
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
|
||||
@@ -131,10 +131,18 @@ impl Gradient {
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
}
|
||||
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
||||
/// segment's midpoint mirrored with it.
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
for s in &mut self.stops {
|
||||
s.offset = 1.0 - s.offset;
|
||||
for i in 0..self.stops.len() {
|
||||
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
|
||||
// one the next stop (not yet mirrored) had, measured from the other end.
|
||||
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
|
||||
if let Some(s) = self.stops.get_mut(i) {
|
||||
s.offset = 1.0 - s.offset;
|
||||
s.midpoint = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
@@ -569,6 +577,32 @@ mod tests {
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
|
||||
/// the other way round.
|
||||
#[test]
|
||||
fn reverse_mirrors_midpoints() {
|
||||
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
||||
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
|
||||
let three =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
|
||||
for g in [two, three] {
|
||||
let mut r = g.clone();
|
||||
r.reverse();
|
||||
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
|
||||
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
|
||||
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
|
||||
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
|
||||
}
|
||||
r.reverse();
|
||||
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
|
||||
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
@@ -57,6 +57,19 @@ impl PaletteFormat {
|
||||
pub fn binary(self) -> bool {
|
||||
self == PaletteFormat::Ase
|
||||
}
|
||||
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
|
||||
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
|
||||
pub fn holds(self, paint: &Paint) -> bool {
|
||||
match paint {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => match self {
|
||||
PaletteFormat::Native => true,
|
||||
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
|
||||
PaletteFormat::Gpl | PaletteFormat::Ase => false,
|
||||
},
|
||||
Paint::None | Paint::Pattern { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The native file: a header around the library.
|
||||
@@ -88,7 +101,7 @@ const ACB_CUT_SHORT: &str = "the color book is cut short";
|
||||
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
|
||||
/// [`read_bytes`] reads from one file.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
|
||||
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
|
||||
let keep = |w: &&Swatch| format.holds(&w.paint);
|
||||
let text = match format {
|
||||
PaletteFormat::Native => {
|
||||
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
|
||||
@@ -792,6 +805,23 @@ mod tests {
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
|
||||
#[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.
|
||||
let mut lib = sample();
|
||||
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
|
||||
mesh.gradient.kind = GradientKind::Freeform;
|
||||
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
|
||||
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
|
||||
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
|
||||
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
|
||||
}
|
||||
let css = written(&lib, PaletteFormat::Css);
|
||||
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
|
||||
}
|
||||
|
||||
/// A swatch exchange file of `blocks` (type, body).
|
||||
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"ASEF\0\x01\0\0".to_vec();
|
||||
@@ -1088,5 +1118,15 @@ mod tests {
|
||||
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
|
||||
}
|
||||
}
|
||||
|
||||
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
|
||||
/// [`PaletteFormat::holds`] counts.
|
||||
#[test]
|
||||
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
|
||||
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -942,7 +942,7 @@ impl Appearance {
|
||||
})
|
||||
}
|
||||
/// Gradient paints of the fills and strokes.
|
||||
fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
|
||||
pub(crate) fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
|
||||
self.items.iter().filter_map(|i| match i {
|
||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
||||
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g),
|
||||
|
||||
@@ -50,7 +50,9 @@ impl Document {
|
||||
|
||||
/// `ids` that are in the document, back to front.
|
||||
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
|
||||
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
|
||||
let ids: Vec<NodeId> = ids.into_iter().collect();
|
||||
let paths = self.index_paths(ids.iter().copied());
|
||||
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| paths.get(&id).map(|p| (p.clone(), id))).collect();
|
||||
v.sort();
|
||||
v.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@ impl Lerp {
|
||||
}
|
||||
}
|
||||
}
|
||||
n.mask = None;
|
||||
n.extras_mut().mask = None;
|
||||
n
|
||||
}
|
||||
Shape::Path { pair, rules, members } => {
|
||||
@@ -590,7 +590,7 @@ impl Lerp {
|
||||
(NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
|
||||
_ => {}
|
||||
}
|
||||
n.mask = None;
|
||||
n.extras_mut().mask = None;
|
||||
n
|
||||
}
|
||||
Shape::Other => {
|
||||
@@ -614,12 +614,12 @@ impl Lerp {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
zero_ids(Arc::make_mut(c));
|
||||
}
|
||||
n.mask = None;
|
||||
n.extras_mut().mask = None;
|
||||
n
|
||||
}
|
||||
};
|
||||
n.id = NodeId(0);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
|
||||
n.blend = base.blend;
|
||||
n.visible = true;
|
||||
@@ -994,7 +994,7 @@ fn paints_opaquely(n: &Node) -> bool {
|
||||
};
|
||||
n.opacity >= 1.0
|
||||
&& n.blend == vectorcraft_color::BlendMode::Normal
|
||||
&& n.mask.is_none()
|
||||
&& n.extras().mask.is_none()
|
||||
&& n.appearance.effects.is_empty()
|
||||
&& n.appearance
|
||||
.items
|
||||
|
||||
+69
-18
@@ -136,38 +136,57 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, data)
|
||||
}
|
||||
|
||||
/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb`
|
||||
/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None`
|
||||
/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder
|
||||
/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB.
|
||||
pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
|
||||
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
|
||||
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
|
||||
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
|
||||
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
|
||||
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar {
|
||||
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
// 0: white is zero, 1: black is zero (the two grayscales).
|
||||
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
|
||||
let extra = d.find_tag_unsigned_vec::<u16>(Tag::ExtraSamples).ok().flatten().and_then(|v| v.first().copied());
|
||||
// (samples in a pixel, how many of them are its colour, is the next one alpha?)
|
||||
let (samples, colour, alpha) = match kind {
|
||||
tiff::ColorType::CMYKA(8 | 16) => (5, 4, true),
|
||||
tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: n @ 2.. } if matches!(photometric, Some(0 | 1)) => {
|
||||
(usize::from(n), 1, matches!(extra, Some(1 | 2)))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if planar {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated.
|
||||
let associated = d.find_tag_unsigned_vec::<u16>(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1));
|
||||
let data: Vec<u8> = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if data.len() != n * 5 {
|
||||
if Some(data.len()) != n.checked_mul(samples) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for p in data.as_chunks::<5>().0 {
|
||||
let a = f32::from(p[4]) / 255.0;
|
||||
let ink = |v: u8| {
|
||||
for p in data.chunks_exact(samples) {
|
||||
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
|
||||
let a = f32::from(opacity) / 255.0;
|
||||
let level = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if associated && a > 0.0 { (v / a).min(1.0) } else { v }
|
||||
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
|
||||
};
|
||||
let [r, g, b] = match *p {
|
||||
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
|
||||
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
|
||||
[v, ..] => [level(v); 3],
|
||||
[] => continue,
|
||||
};
|
||||
let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]);
|
||||
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
out.push(p[4]);
|
||||
out.push(opacity);
|
||||
}
|
||||
image::RgbaImage::from_raw(w, h, out)
|
||||
}
|
||||
@@ -187,12 +206,44 @@ mod tests {
|
||||
img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap();
|
||||
let bytes = file.into_inner();
|
||||
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
|
||||
let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads");
|
||||
let px = extra_samples_tiff_rgba(&bytes, naive).expect("CMYKA reads");
|
||||
assert_eq!(px.dimensions(), (2, 1));
|
||||
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque");
|
||||
assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha");
|
||||
// Not CMYKA: not this reader's.
|
||||
assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
|
||||
assert!(extra_samples_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
|
||||
}
|
||||
|
||||
/// Two 8-bit samples a pixel: gray and one extra sample, which the file's `ExtraSamples` names.
|
||||
struct GrayExtra8;
|
||||
impl tiff::encoder::colortype::ColorType for GrayExtra8 {
|
||||
type Inner = u8;
|
||||
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
|
||||
const BITS_PER_SAMPLE: &'static [u16] = &[8, 8];
|
||||
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
|
||||
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
|
||||
result.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// A grayscale TIFF with an alpha channel, or with a channel that isn't one (#1466): the image
|
||||
/// decoder reads neither.
|
||||
#[test]
|
||||
fn a_grayscale_tiff_with_an_extra_channel_reads_as_rgba() {
|
||||
let gray = |extra: u16| {
|
||||
let mut file = Cursor::new(Vec::new());
|
||||
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
|
||||
let mut img = enc.new_image::<GrayExtra8>(3, 1).unwrap();
|
||||
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
|
||||
// White opaque, a gray of 64 at 80% alpha (premultiplied: 51), black with no alpha.
|
||||
img.write_data(&[255, 255, 51, 204, 0, 0]).unwrap();
|
||||
let bytes = file.into_inner();
|
||||
assert!(image::load_from_memory(&bytes).is_err(), "the image decoder reads it now: this reader may go");
|
||||
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with an extra sample reads").into_raw()
|
||||
};
|
||||
assert_eq!(gray(1), [255, 255, 255, 255, 64, 64, 64, 204, 0, 0, 0, 0], "associated alpha, divided back out");
|
||||
assert_eq!(gray(2), [255, 255, 255, 255, 51, 51, 51, 204, 0, 0, 0, 0], "unassociated alpha");
|
||||
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
|
||||
}
|
||||
|
||||
fn inks() -> Inks {
|
||||
|
||||
+11
-11
@@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> {
|
||||
/// The Live Corners of a path object.
|
||||
pub fn of(node: &'a Node) -> Option<Self> {
|
||||
let NodeKind::Path { path, live, .. } = &node.kind else { return None };
|
||||
Self::new(path, live.as_ref())
|
||||
Self::new(path, live.as_deref())
|
||||
}
|
||||
|
||||
/// The corner at anchor `index` of `base`.
|
||||
@@ -127,7 +127,7 @@ impl<'a> LiveCorners<'a> {
|
||||
/// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is 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<LiveShape>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
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>) {
|
||||
for k in corners {
|
||||
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
|
||||
@@ -158,17 +158,17 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<LiveShape>, corners: &
|
||||
kinds.clear();
|
||||
}
|
||||
}
|
||||
let l = live.get_or_insert_with(|| 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 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();
|
||||
match l {
|
||||
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::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
}
|
||||
match live {
|
||||
match live.as_deref_mut() {
|
||||
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
|
||||
*path = std::mem::take(base);
|
||||
*live = None;
|
||||
@@ -220,15 +220,15 @@ 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 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_ref()).unwrap();
|
||||
let c = LiveCorners::new(&path, live.as_deref()).unwrap();
|
||||
assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round)));
|
||||
assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path");
|
||||
// Kinds alone keep it live; squaring every corner makes it the plain star again.
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer));
|
||||
assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
|
||||
assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round));
|
||||
assert_eq!((path, live), (star, None));
|
||||
}
|
||||
@@ -237,16 +237,16 @@ mod tests {
|
||||
#[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 path, mut live) = (l.to_path(), Some(l.clone()));
|
||||
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);
|
||||
l = live.clone().unwrap();
|
||||
l = *live.clone().unwrap();
|
||||
l.set_sides(8);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8]));
|
||||
assert_eq!(l.to_path().anchor_count(), 16);
|
||||
// Mixed radii: the new corners are sharp.
|
||||
set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None);
|
||||
let mut l = live.unwrap();
|
||||
let mut l = *live.unwrap();
|
||||
l.set_sides(7);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0]));
|
||||
// An uneven scale keeps the cuts circular: the radius scales by the mean scale.
|
||||
|
||||
+346
-3
@@ -1,8 +1,15 @@
|
||||
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
|
||||
/// for the data window, so a file can't make it allocate without bound.
|
||||
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
|
||||
pub const MAX_GRAPH_SERIES: usize = 256;
|
||||
|
||||
/// The nine graph types, in the Graph tool group's order.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -62,6 +69,186 @@ impl GraphKind {
|
||||
GraphKind::Radar => "Radar",
|
||||
}
|
||||
}
|
||||
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
|
||||
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
|
||||
/// Scatter, pie and radar lay their data out on their own and take no other types.
|
||||
pub fn combines_with(self, graph: GraphKind) -> bool {
|
||||
use GraphKind::*;
|
||||
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
|
||||
let horizontal = |k| matches!(k, Bar | StackedBar);
|
||||
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph design (Object › Graph › Design…): named art that graphs draw in place of their default marks.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GraphDesign {
|
||||
pub name: String,
|
||||
pub art: std::sync::Arc<crate::Node>,
|
||||
}
|
||||
|
||||
impl GraphDesign {
|
||||
/// Most designs a document keeps, objects in one design's art, and characters in a design's name.
|
||||
pub const MAX: usize = 1000;
|
||||
pub const MAX_NODES: usize = 2000;
|
||||
pub const MAX_NAME: usize = 64;
|
||||
|
||||
/// A design name cleaned up: trimmed, without control characters, at most [`Self::MAX_NAME`] long; `None` when
|
||||
/// nothing is left.
|
||||
pub fn clean_name(name: &str) -> Option<String> {
|
||||
let n: String = name.trim().chars().filter(|c| !c.is_control()).take(Self::MAX_NAME).collect();
|
||||
Some(n.trim().to_string()).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// `art` as plain design art: a graph inside it becomes the plain group it draws as, so a marker never holds a
|
||||
/// graph of its own (that its commands would then edit).
|
||||
pub fn plain_art(art: &crate::Node) -> crate::Node {
|
||||
let mut n = art.clone();
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.graph = None;
|
||||
e.series_index = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// The graph designs a file gave, made safe to list and draw: at most [`GraphDesign::MAX`], each with a clean,
|
||||
/// unique name, art of at most [`GraphDesign::MAX_NODES`] objects and no graph inside.
|
||||
pub fn tidy_graph_designs(&mut self) {
|
||||
let mut kept: Vec<GraphDesign> = vec![];
|
||||
for d in std::mem::take(&mut self.graph_designs) {
|
||||
if kept.len() >= GraphDesign::MAX {
|
||||
break;
|
||||
}
|
||||
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
|
||||
if kept.iter().any(|k| k.name == name) || d.art.count() > GraphDesign::MAX_NODES {
|
||||
continue;
|
||||
}
|
||||
kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) });
|
||||
}
|
||||
self.graph_designs = kept;
|
||||
}
|
||||
}
|
||||
|
||||
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
|
||||
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
|
||||
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LabelStyles {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend: Option<crate::CharStyle>,
|
||||
}
|
||||
|
||||
impl LabelStyles {
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or
|
||||
/// stacked one on another, sized by their totals (Stacked).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PiePosition {
|
||||
#[default]
|
||||
Even,
|
||||
Ratio,
|
||||
Stacked,
|
||||
}
|
||||
|
||||
/// Graph Type › Sort for pie graphs: wedges in data order (None), largest first in each pie (All), or in the first
|
||||
/// pie's largest-first order in every pie (First).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PieSort {
|
||||
#[default]
|
||||
None,
|
||||
All,
|
||||
First,
|
||||
}
|
||||
|
||||
macro_rules! ids {
|
||||
($t:ty { $($v:ident => $id:literal),* }) => {
|
||||
impl $t {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self { $(Self::$v => $id),* }
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[$(Self::$v),*].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
|
||||
ids!(PieSort { None => "none", All => "all", First => "first" });
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ValueAxisSide {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ValueAxisSide {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ValueAxisSide::Left => "left",
|
||||
ValueAxisSide::Right => "right",
|
||||
ValueAxisSide::Both => "both",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_left(&self) -> bool {
|
||||
*self == ValueAxisSide::Left
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Tick Marks › Length: no tick marks, short ones outside the axis, or lines across the whole plot.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TickLength {
|
||||
None,
|
||||
#[default]
|
||||
Short,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl TickLength {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TickLength::None => "none",
|
||||
TickLength::Short => "short",
|
||||
TickLength::Full => "full",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[TickLength::None, TickLength::Short, TickLength::Full].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_short(&self) -> bool {
|
||||
*self == TickLength::Short
|
||||
}
|
||||
fn is_none(&self) -> bool {
|
||||
*self == TickLength::None
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
|
||||
@@ -88,14 +275,22 @@ pub struct GraphSpec {
|
||||
/// entries keep the generator's own fill and stroke.
|
||||
pub series_paints: Vec<SeriesPaint>,
|
||||
pub kind: GraphKind,
|
||||
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
|
||||
/// entries draw as [`Self::kind`].
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_kinds: Vec<Option<GraphKind>>,
|
||||
/// The plot rectangle (the area the graph tool dragged), document points.
|
||||
pub rect: Rect,
|
||||
pub series: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub rows: Vec<Vec<f64>>,
|
||||
/// Column/bar width as a % of the available category width (90).
|
||||
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
|
||||
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub blanks: Vec<[usize; 2]>,
|
||||
/// Column/bar width as a % of the available category width (90); past 100 the columns overlap.
|
||||
pub column_width: f64,
|
||||
/// Cluster width as a % (80).
|
||||
/// Cluster width as a % (80); past 100 the clusters overlap.
|
||||
pub cluster_width: f64,
|
||||
/// Legend on the right (Graph Type options: Add Legend Across Top = false).
|
||||
pub legend: bool,
|
||||
@@ -103,31 +298,179 @@ pub struct GraphSpec {
|
||||
pub ticks: usize,
|
||||
pub axis_min: Option<f64>,
|
||||
pub axis_max: Option<f64>,
|
||||
/// Line graphs: mark data points, connect them, fill (area-like) the lines.
|
||||
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
|
||||
/// runs along the bottom and stays there).
|
||||
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
|
||||
pub value_axis: ValueAxisSide,
|
||||
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
|
||||
/// ([`Self::right_series`]) get a scale of their own there.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub separate_scales: bool,
|
||||
/// Indexes of the series assigned to the right value axis.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub right_series: Vec<usize>,
|
||||
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub right_ticks: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_axis_min: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_axis_max: Option<f64>,
|
||||
/// The labels' character styles, kept from formatting them.
|
||||
#[serde(skip_serializing_if = "LabelStyles::is_default")]
|
||||
pub label_styles: LabelStyles,
|
||||
/// Graph Type › First Row in Front (each series' first category drawn over the next when columns overlap) and
|
||||
/// First Column in Front (the first series drawn over the others); Draw Filled Lines (lines as filled bands).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub first_row_in_front: bool,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub first_column_in_front: bool,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub filled_lines: bool,
|
||||
/// Graph Type › Add Drop Shadow (behind columns, bars, lines and pie wedges) and Add Legend Across Top (the
|
||||
/// legend in rows above the plot instead of a column on its right).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub drop_shadow: bool,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub legend_across_top: bool,
|
||||
/// Pie graphs: Legends in Wedges (the series labels inside their wedges instead of a legend beside the pies),
|
||||
/// Position and Sort.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub pie_legend_in_wedges: bool,
|
||||
#[serde(skip_serializing_if = "PiePosition::is_default")]
|
||||
pub pie_position: PiePosition,
|
||||
#[serde(skip_serializing_if = "PieSort::is_default")]
|
||||
pub pie_sort: PieSort,
|
||||
/// Object › Graph › Marker: the graph design each series' data points (and legend swatch) are drawn with, by
|
||||
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_markers: Vec<Option<String>>,
|
||||
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
|
||||
/// length, and tick marks per division (0 and 1 = one, at the labels).
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
pub tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub tick_marks: usize,
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
pub right_tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub right_tick_marks: usize,
|
||||
/// Tick Marks for the category axis (none by default), and Draw Tick Marks Between Labels.
|
||||
#[serde(skip_serializing_if = "TickLength::is_none")]
|
||||
pub category_tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub category_tick_marks: usize,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ticks_between_labels: bool,
|
||||
/// Graph Type › Add Labels: text before and after the value axes' numbers.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub prefix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub suffix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub right_prefix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub right_suffix: String,
|
||||
/// Line, scatter and radar graphs: mark data points, connect them.
|
||||
pub mark_points: bool,
|
||||
pub connect_points: bool,
|
||||
/// Line graphs: run the lines from edge to edge of the plot; off (the default for new graphs), the points sit
|
||||
/// at the centres of their categories, like the columns of a column graph. Graphs saved before the option
|
||||
/// existed were drawn edge to edge and keep it.
|
||||
#[serde(default = "edge_to_edge_before_the_option")]
|
||||
pub edge_to_edge: bool,
|
||||
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub placed: Option<Rect>,
|
||||
}
|
||||
|
||||
fn at_most_one(n: &usize) -> bool {
|
||||
*n <= 1
|
||||
}
|
||||
|
||||
fn is_zero(n: &usize) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
|
||||
fn edge_to_edge_before_the_option() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
|
||||
pub fn series_kind(&self, s: usize) -> GraphKind {
|
||||
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
|
||||
}
|
||||
|
||||
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
|
||||
/// of [`MAX_GRAPH_SERIES`] cells).
|
||||
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
|
||||
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
|
||||
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
|
||||
self.rows
|
||||
.iter()
|
||||
.take(MAX_GRAPH_CATEGORIES)
|
||||
.enumerate()
|
||||
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
|
||||
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
|
||||
self.blanks =
|
||||
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
|
||||
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
series_paints: vec![],
|
||||
kind: GraphKind::Column,
|
||||
series_kinds: vec![],
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
|
||||
series: vec![],
|
||||
categories: vec![],
|
||||
rows: vec![vec![1.0]],
|
||||
blanks: vec![],
|
||||
column_width: 90.0,
|
||||
cluster_width: 80.0,
|
||||
legend: true,
|
||||
ticks: 0,
|
||||
axis_min: None,
|
||||
axis_max: None,
|
||||
value_axis: ValueAxisSide::Left,
|
||||
separate_scales: false,
|
||||
right_series: vec![],
|
||||
right_ticks: 0,
|
||||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
series_markers: vec![],
|
||||
label_styles: LabelStyles::default(),
|
||||
first_row_in_front: false,
|
||||
first_column_in_front: false,
|
||||
filled_lines: false,
|
||||
drop_shadow: false,
|
||||
legend_across_top: false,
|
||||
pie_legend_in_wedges: false,
|
||||
pie_position: PiePosition::Even,
|
||||
pie_sort: PieSort::None,
|
||||
tick_length: TickLength::Short,
|
||||
tick_marks: 1,
|
||||
right_tick_length: TickLength::Short,
|
||||
right_tick_marks: 1,
|
||||
category_tick_length: TickLength::None,
|
||||
category_tick_marks: 1,
|
||||
ticks_between_labels: false,
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
right_prefix: String::new(),
|
||||
right_suffix: String::new(),
|
||||
mark_points: true,
|
||||
connect_points: true,
|
||||
edge_to_edge: false,
|
||||
placed: None,
|
||||
}
|
||||
}
|
||||
|
||||
+84
-24
@@ -31,6 +31,10 @@ impl Hit {
|
||||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||||
// A single object isolated: it is the only object.
|
||||
if scope == Some(self.leaf) {
|
||||
return self.leaf;
|
||||
}
|
||||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
@@ -66,7 +70,13 @@ impl Default for HitOptions {
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false)
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
}
|
||||
|
||||
/// Topmost visible art for read-only sampling: locked objects and template layers participate,
|
||||
/// including art outside the editing scope. This never changes their editability.
|
||||
pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, false)
|
||||
}
|
||||
|
||||
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
|
||||
@@ -75,7 +85,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
let mut tops: Vec<NodeId> = vec![];
|
||||
// Each round leaves out the objects found so far (and everything inside them).
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
|
||||
if tops.contains(&top) {
|
||||
break;
|
||||
}
|
||||
@@ -85,7 +95,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
|
||||
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();
|
||||
@@ -93,27 +103,31 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
};
|
||||
// Isolation mode: only what is inside the isolated container hits. The art around it is
|
||||
// locked, and clicks pass through it (#833).
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
|
||||
if editable_only
|
||||
&& let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
&& let Some(mut chain) = doc.ancestry(scope)
|
||||
{
|
||||
return hit_children(container, p, opt, skip, &mut chain).map(finish);
|
||||
if isolated.is_container() {
|
||||
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
|
||||
}
|
||||
// A single object isolated (a path, an image…): only it hits, among its siblings.
|
||||
chain.pop();
|
||||
let parent = chain.last().and_then(|id| doc.node(*id))?;
|
||||
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
|
||||
}
|
||||
let mut chain = Vec::new();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked {
|
||||
continue;
|
||||
}
|
||||
if layer.is_template() {
|
||||
if !layer.visible || editable_only && (layer.locked || layer.is_template()) {
|
||||
continue;
|
||||
}
|
||||
// Pattern editing mode: only the tile art is editable.
|
||||
if doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
if editable_only && doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain) {
|
||||
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
|
||||
return Some(finish(h));
|
||||
}
|
||||
chain.pop();
|
||||
@@ -136,7 +150,14 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, chain: &mut Vec<NodeId>) -> Option<Hit> {
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
opt: HitOptions,
|
||||
skip: &dyn Fn(NodeId) -> bool,
|
||||
chain: &mut Vec<NodeId>,
|
||||
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()
|
||||
@@ -146,19 +167,19 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.shaper.is_some() {
|
||||
if parent.extras().shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() || skip(c.id) {
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !(edits_contents(c)
|
||||
|| c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
@@ -167,13 +188,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain),
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain),
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain, editable_only),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain)
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain, editable_only)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
};
|
||||
@@ -272,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
Some(s) => match s.children() {
|
||||
Some(c) => c.iter().collect(),
|
||||
// A single object isolated: only it.
|
||||
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
|
||||
},
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
None => doc
|
||||
@@ -375,6 +400,20 @@ mod tests {
|
||||
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
|
||||
/// outline it doesn't touch.
|
||||
#[test]
|
||||
fn a_marquee_inside_a_background_leaves_the_background() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (back, small) = (d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
|
||||
// Reaching the background's edge takes it too.
|
||||
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_top_object() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
@@ -449,7 +488,7 @@ mod tests {
|
||||
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
|
||||
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
|
||||
compound.appearance = Appearance::default_art();
|
||||
compound.appearance = Appearance::default_art().into();
|
||||
d.insert(Some(l), 9, compound).unwrap();
|
||||
let path_only = HitOptions { path_only: true, ..Default::default() };
|
||||
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
|
||||
@@ -509,7 +548,7 @@ mod tests {
|
||||
let l = d.layers[0].id;
|
||||
let mut add = |t: TextObject, perspective: Option<Affine>| {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
n.perspective = perspective.map(|a| {
|
||||
n.extras_mut().perspective = perspective.map(|a| {
|
||||
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
|
||||
});
|
||||
let id = n.id;
|
||||
@@ -592,4 +631,25 @@ world",
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let a = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let p = Point::new(10.0, 10.0);
|
||||
for lock in [a, l] {
|
||||
d.node_mut(lock).unwrap().locked = true;
|
||||
assert!(hit_test(&d, p, HitOptions::default()).is_none());
|
||||
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a);
|
||||
assert!(d.node(lock).unwrap().locked);
|
||||
d.node_mut(lock).unwrap().locked = false;
|
||||
}
|
||||
d.node_mut(a).unwrap().visible = false;
|
||||
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
|
||||
d.node_mut(a).unwrap().visible = true;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! A cache of every node's index path, so a lookup by id checks one path instead of walking the
|
||||
//! tree.
|
||||
//!
|
||||
//! The tree is edited in many places through public fields, so the cache can't be kept in step
|
||||
//! with it. It is checked instead: a cached path is used only while the node at that path still
|
||||
//! has the id (a check as deep as the tree). A path that no longer checks out rebuilds the cache,
|
||||
//! and an id the cache doesn't have (deleted, or added since) is looked up by walking, as before.
|
||||
//! An id that appears more than once (a damaged file) is never cached, so lookups keep returning
|
||||
//! its first place in tree order.
|
||||
//!
|
||||
//! The one case a cached lookup can answer differently from a walk: a second node with an
|
||||
//! existing id put in the tree *after* the cache was built, earlier in tree order than the first.
|
||||
//! The lookup then returns the original node (also one with that id) instead of the new one. The
|
||||
//! app never makes such a node (paste and duplicate give new ids, undo restores whole trees);
|
||||
//! duplicated ids come from damaged files, which are cached afresh when opened.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::{Node, NodeId};
|
||||
|
||||
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
|
||||
/// default SipHash dominated the cost of building the cache.
|
||||
#[derive(Default)]
|
||||
pub struct IdHasher(u64);
|
||||
|
||||
impl Hasher for IdHasher {
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, v: u64) {
|
||||
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
}
|
||||
fn finish(&self) -> u64 {
|
||||
self.0 ^ (self.0 >> 29)
|
||||
}
|
||||
}
|
||||
|
||||
/// A map keyed by node id with [`IdHasher`].
|
||||
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
|
||||
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
|
||||
/// its siblings. One flat table, so building it allocates nothing per node.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Paths {
|
||||
/// Each id's entry in `at`; `None` for an id that appears more than once.
|
||||
ids: IdMap<Option<u32>>,
|
||||
at: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
/// The index path of `id`: `Some(None)` when the id appears more than once, `None` when it
|
||||
/// isn't in the table.
|
||||
pub(crate) fn get(&self, id: &NodeId) -> Option<Option<Vec<usize>>> {
|
||||
let e = (*self.ids.get(id)?)?;
|
||||
let mut path = Vec::new();
|
||||
let mut cur = e;
|
||||
// Each step goes to a parent entry, made before its children: at most `at.len()` steps.
|
||||
for _ in 0..=self.at.len() {
|
||||
let Some(&(parent, i)) = self.at.get(cur as usize) else { return Some(None) };
|
||||
path.push(i as usize);
|
||||
if parent == u32::MAX {
|
||||
path.reverse();
|
||||
return Some(Some(path));
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
Some(None)
|
||||
}
|
||||
pub(crate) fn contains_key(&self, id: &NodeId) -> bool {
|
||||
self.ids.contains_key(id)
|
||||
}
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookups that had to walk (the cache didn't have the id, or its path no longer checks out)
|
||||
/// before the cache is rebuilt: a rebuild costs a few walks, so rebuilding only after a few keeps
|
||||
/// a lookup's cost within a small factor of a walk whatever the edit pattern (say, delete an
|
||||
/// object and look the next one up, again and again).
|
||||
const MISSES_PER_REBUILD: u32 = 4;
|
||||
|
||||
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
|
||||
#[derive(Default)]
|
||||
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
|
||||
|
||||
impl IdIndex {
|
||||
/// The current cache, if one was built.
|
||||
pub(crate) fn get(&self) -> Option<Arc<Paths>> {
|
||||
self.0.read().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
/// Let go of the cache (a document kept in the undo history): the next lookup builds it again.
|
||||
pub(crate) fn clear(&self) {
|
||||
if let Ok(mut g) = self.0.write() {
|
||||
*g = None;
|
||||
}
|
||||
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether a cache was built (and not cleared since).
|
||||
pub(crate) fn is_built(&self) -> bool {
|
||||
self.0.read().is_ok_and(|g| g.is_some())
|
||||
}
|
||||
|
||||
/// Rebuild the cache from `layers` (one walk) and return it.
|
||||
pub(crate) fn rebuild(&self, layers: &[Arc<Node>]) -> Arc<Paths> {
|
||||
fn walk(nodes: &[Arc<Node>], parent: u32, out: &mut Paths) {
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let e = out.at.len() as u32;
|
||||
out.at.push((parent, i as u32));
|
||||
out.ids.entry(n.id).and_modify(|x| *x = None).or_insert(Some(e));
|
||||
if let Some(ch) = n.children() {
|
||||
walk(ch, e, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Paths::default();
|
||||
walk(layers, u32::MAX, &mut out);
|
||||
let out = Arc::new(out);
|
||||
if let Ok(mut g) = self.0.write() {
|
||||
*g = Some(out.clone());
|
||||
}
|
||||
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
out
|
||||
}
|
||||
|
||||
/// Note a lookup that had to walk (an id the cache didn't have, or a path that no longer
|
||||
/// checks out); true when it's time to rebuild.
|
||||
pub(crate) fn missed(&self) -> bool {
|
||||
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 >= MISSES_PER_REBUILD
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for IdIndex {
|
||||
fn clone(&self) -> Self {
|
||||
IdIndex(RwLock::new(self.get()), Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache, not document content: two documents are equal whatever their caches hold.
|
||||
impl PartialEq for IdIndex {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IdIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("IdIndex")
|
||||
}
|
||||
}
|
||||
+409
-38
@@ -13,12 +13,16 @@ pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
pub mod inks;
|
||||
mod inline;
|
||||
pub mod shared;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod orient;
|
||||
@@ -41,6 +45,7 @@ pub mod style_libs;
|
||||
pub mod swatches;
|
||||
pub mod text;
|
||||
pub mod trace;
|
||||
pub mod variables;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -56,6 +61,12 @@ pub(crate) mod skip {
|
||||
pub fn is_one(v: &f32) -> bool {
|
||||
*v == 1.0
|
||||
}
|
||||
pub fn is_empty_vec<T>(v: &[T]) -> bool {
|
||||
v.is_empty()
|
||||
}
|
||||
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
v.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub use appearance::StrokeGradientMode;
|
||||
@@ -65,15 +76,18 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec, SeriesPaint};
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
@@ -81,16 +95,17 @@ pub use placed_document::PlacedDocument;
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use selection::{AnchorRef, SegmentRef, Selection};
|
||||
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, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
|
||||
@@ -400,12 +415,17 @@ pub struct Guide {
|
||||
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artboard: Option<u32>,
|
||||
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
|
||||
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
|
||||
/// from before guides had layers, and from files that don't say), always there.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// A canvas guide.
|
||||
pub fn new(vertical: bool, pos: f64) -> Self {
|
||||
Self { vertical, pos, artboard: None }
|
||||
Self { vertical, pos, artboard: None, layer: None }
|
||||
}
|
||||
|
||||
/// Moved by `d` (a vertical guide across, a horizontal one down).
|
||||
@@ -531,36 +551,48 @@ pub struct Document {
|
||||
pub template: bool,
|
||||
#[serde(default)]
|
||||
pub units: Unit,
|
||||
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub ruler_origin: Point,
|
||||
#[serde(default)]
|
||||
pub color_mode: ColorMode,
|
||||
pub artboards: Vec<Artboard>,
|
||||
/// Top-level layers, bottom first.
|
||||
pub layers: Vec<Arc<Node>>,
|
||||
// The document-wide resources below are [`Shared`]: an edit copies the document (copy-on-write)
|
||||
// and the undo history keeps every version, so what an edit doesn't change stays one copy.
|
||||
// Reads, writes and the saved form are those of the value itself.
|
||||
#[serde(default)]
|
||||
pub swatches: Vec<Swatch>,
|
||||
pub swatches: Shared<Vec<Swatch>>,
|
||||
#[serde(default)]
|
||||
pub swatch_groups: Vec<SwatchGroup>,
|
||||
pub swatch_groups: Shared<Vec<SwatchGroup>>,
|
||||
#[serde(default)]
|
||||
pub graphic_styles: Vec<GraphicStyle>,
|
||||
pub graphic_styles: Shared<Vec<GraphicStyle>>,
|
||||
/// Character styles (besides the built-in [Normal Character Style], which may be redefined here).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub char_styles: Vec<TextStyleDef>,
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||||
pub char_styles: Shared<Vec<TextStyleDef>>,
|
||||
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||||
pub para_styles: Shared<Vec<TextStyleDef>>,
|
||||
/// Threaded text: area-type frames one story flows through, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub text_threads: Vec<Vec<NodeId>>,
|
||||
#[serde(default)]
|
||||
pub symbols: Vec<Symbol>,
|
||||
/// Object › Graph › Design…: art graphs can draw as markers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub graph_designs: Vec<GraphDesign>,
|
||||
#[serde(default)]
|
||||
pub guides: Vec<Guide>,
|
||||
/// View → New View… (up to 25, like Illustrator).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub views: Vec<SavedView>,
|
||||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub saved_selections: Vec<SavedSelection>,
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||||
pub saved_selections: Shared<Vec<SavedSelection>>,
|
||||
/// Variables (data merge): named values bound to objects, applied per dataset.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub variables: Shared<Variables>,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
@@ -579,8 +611,8 @@ pub struct Document {
|
||||
pub mask_edit: Option<MaskEdit>,
|
||||
next_id: u64,
|
||||
/// Foreign data preserved on round-trip.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub unknown: serde_json::Map<String, serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
|
||||
pub unknown: Shared<serde_json::Map<String, serde_json::Value>>,
|
||||
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
|
||||
/// so top-level blend modes don't blend with what lies under the page.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -648,6 +680,9 @@ pub struct Document {
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
|
||||
#[serde(skip)]
|
||||
index: id_index::IdIndex,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
@@ -658,6 +693,10 @@ fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||||
/// of each listed child in it, in order, with the child's place in the list.
|
||||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
@@ -674,6 +713,7 @@ impl Document {
|
||||
title: "Untitled-1".into(),
|
||||
template: false,
|
||||
units: Unit::Points,
|
||||
ruler_origin: Point::ZERO,
|
||||
color_mode: mode,
|
||||
artboards: vec![Artboard {
|
||||
id: 1,
|
||||
@@ -683,16 +723,18 @@ impl Document {
|
||||
show_cross_hairs: false,
|
||||
}],
|
||||
layers: vec![],
|
||||
swatches,
|
||||
swatch_groups,
|
||||
graphic_styles: default_graphic_styles(),
|
||||
char_styles: vec![],
|
||||
para_styles: vec![],
|
||||
swatches: swatches.into(),
|
||||
swatch_groups: swatch_groups.into(),
|
||||
graphic_styles: default_graphic_styles().into(),
|
||||
char_styles: Default::default(),
|
||||
para_styles: Default::default(),
|
||||
text_threads: vec![],
|
||||
symbols: vec![],
|
||||
graph_designs: vec![],
|
||||
guides: vec![],
|
||||
views: vec![],
|
||||
saved_selections: vec![],
|
||||
saved_selections: Default::default(),
|
||||
variables: Default::default(),
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
@@ -718,6 +760,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
@@ -747,24 +790,51 @@ impl Document {
|
||||
|
||||
/// Find a node anywhere in the tree.
|
||||
pub fn node(&self, id: NodeId) -> Option<&Node> {
|
||||
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Node> {
|
||||
for n in nodes {
|
||||
if n.id == id {
|
||||
return Some(n);
|
||||
}
|
||||
if let Some(ch) = n.children()
|
||||
&& let Some(f) = find(ch, id)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
find(&self.layers, id)
|
||||
self.index_path(id).and_then(|p| self.node_at(&p))
|
||||
}
|
||||
|
||||
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`).
|
||||
/// [`Document::node`] without building the lookup cache when there is none: for a look at a
|
||||
/// document kept in the undo history ([`Document::drop_lookup_cache`]).
|
||||
pub fn node_uncached(&self, id: NodeId) -> Option<&Node> {
|
||||
match self.index.is_built() {
|
||||
true => self.node(id),
|
||||
false => self.walk_index_path(id).and_then(|p| self.node_at(&p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a
|
||||
/// table per step would add up. Lookups still work; the first one builds the cache again.
|
||||
pub fn drop_lookup_cache(&self) {
|
||||
self.index.clear();
|
||||
}
|
||||
|
||||
/// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]).
|
||||
pub fn has_lookup_cache(&self) -> bool {
|
||||
self.index.is_built()
|
||||
}
|
||||
|
||||
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
|
||||
/// tree order where `id` appears.
|
||||
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
|
||||
// The cached path, while the node there still has the id ([`id_index`]).
|
||||
let cached = self.index.get();
|
||||
match cached.as_ref().and_then(|m| m.get(&id)) {
|
||||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
|
||||
Some(None) => return self.walk_index_path(id),
|
||||
_ => {}
|
||||
}
|
||||
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
|
||||
// or no cache yet: walk, and rebuild now and then.
|
||||
let found = self.walk_index_path(id);
|
||||
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
|
||||
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
|
||||
self.index.rebuild(&self.layers);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// [`Document::index_path`] by walking the tree.
|
||||
fn walk_index_path(&self, id: NodeId) -> Option<Vec<usize>> {
|
||||
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
path.push(i);
|
||||
@@ -784,6 +854,85 @@ impl Document {
|
||||
find(&self.layers, id, &mut p).then_some(p)
|
||||
}
|
||||
|
||||
/// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an
|
||||
/// edit of many objects looks them all up for the cost of one search instead of one each. Like
|
||||
/// `index_path`, an id that appears twice maps to its first place in tree order.
|
||||
pub fn index_paths(&self, ids: impl IntoIterator<Item = NodeId>) -> std::collections::HashMap<NodeId, Vec<usize>> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
fn walk(nodes: &[Arc<Node>], want: &HashSet<NodeId>, path: &mut Vec<usize>, out: &mut HashMap<NodeId, Vec<usize>>) -> bool {
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
path.push(i);
|
||||
if want.contains(&n.id) && !out.contains_key(&n.id) {
|
||||
out.insert(n.id, path.clone());
|
||||
if out.len() == want.len() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children()
|
||||
&& walk(ch, want, path, out)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut want: HashSet<NodeId> = ids.into_iter().collect();
|
||||
let mut out = HashMap::with_capacity(want.len());
|
||||
// Cached paths that still check out ([`id_index`]); the rest take one walk. For a good
|
||||
// share of the document, one walk is quicker than checking each cached path.
|
||||
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
|
||||
want.retain(|id| match cache.get(id) {
|
||||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
|
||||
out.insert(*id, p);
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
if !want.is_empty() {
|
||||
let mut rest = HashMap::with_capacity(want.len());
|
||||
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
|
||||
out.extend(rest);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The node at index path `path` (as [`Document::index_path`] gives it).
|
||||
pub fn node_at(&self, path: &[usize]) -> Option<&Node> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut cur: &Node = self.layers.get(*first)?;
|
||||
for &i in rest {
|
||||
cur = cur.children()?.get(i)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as
|
||||
/// [`Document::node_mut`] does.
|
||||
pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut cur: &mut Arc<Node> = self.layers.get_mut(*first)?;
|
||||
for &i in rest {
|
||||
cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?;
|
||||
}
|
||||
Some(Arc::make_mut(cur))
|
||||
}
|
||||
|
||||
/// [`Document::ancestry`] of the node at index path `path`.
|
||||
pub fn ancestry_at(&self, path: &[usize]) -> Option<Vec<NodeId>> {
|
||||
let mut out = Vec::with_capacity(path.len());
|
||||
let mut nodes = &self.layers;
|
||||
for &i in path {
|
||||
let n = nodes.get(i)?;
|
||||
out.push(n.id);
|
||||
if let Some(ch) = n.children() {
|
||||
nodes = ch;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Ids from the top-level layer down to `id` inclusive.
|
||||
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
|
||||
let path = self.index_path(id)?;
|
||||
@@ -876,6 +1025,128 @@ impl Document {
|
||||
Some((parent, idx, n))
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
|
||||
/// children of each parent. Each id is listed once and none lies inside another listed node, as
|
||||
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
|
||||
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let removed = self.take(places, ids.len())?;
|
||||
self.index.clear();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
|
||||
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
|
||||
/// index less the number of listed nodes below it there. Returns the new node's id.
|
||||
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let last = ids.len().checked_sub(1);
|
||||
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
|
||||
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
|
||||
// The parent and each node it is in move down by the listed nodes before them.
|
||||
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
|
||||
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
|
||||
let at = i.saturating_sub(below(&up, i));
|
||||
let node = make(self.take(places, ids.len())?);
|
||||
let id = node.id;
|
||||
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
|
||||
let at = at.min(children.len());
|
||||
children.insert(at, Arc::new(node));
|
||||
self.index.clear();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
|
||||
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
|
||||
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
|
||||
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
|
||||
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||||
let places = self.places(&anchors)?;
|
||||
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
|
||||
self.edit_children(places, |k, anchor, children| {
|
||||
if !replace {
|
||||
children.push(anchor);
|
||||
}
|
||||
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
|
||||
children.extend(nodes.into_iter().map(Arc::new));
|
||||
})?;
|
||||
self.index.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
|
||||
/// is listed twice or lies inside another listed node.
|
||||
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
|
||||
let paths = self.index_paths(ids.iter().copied());
|
||||
let mut places = Places::new();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
|
||||
match places.get_mut(up) {
|
||||
Some(kids) => kids.push((i, k)),
|
||||
None => {
|
||||
places.insert(up.to_vec(), vec![(i, k)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for kids in places.values_mut() {
|
||||
kids.sort_unstable();
|
||||
}
|
||||
// An id listed twice has one path, so its parent lists the same child twice. A listed node
|
||||
// lies inside another when its parent's path, or a part of it from the top, is the path of a
|
||||
// listed node.
|
||||
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
|
||||
let listed = |path: &[usize]| {
|
||||
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
|
||||
};
|
||||
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
|
||||
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
|
||||
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
|
||||
return Err(DocError::NoNode(*id));
|
||||
}
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
|
||||
/// which is `len` long.
|
||||
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let mut taken = vec![None; len];
|
||||
self.edit_children(places, |k, node, _| {
|
||||
if let Some(t) = taken.get_mut(k) {
|
||||
*t = Some(node);
|
||||
}
|
||||
})?;
|
||||
Ok(taken.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
/// Rebuild the children of each parent in `places` once, the parents later in tree order
|
||||
/// first. `each` gets each listed child, its place in the list and the parent's new children,
|
||||
/// onto which it pushes what takes the child's place.
|
||||
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
|
||||
for (up, kids) in places.into_iter().rev() {
|
||||
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
|
||||
let old = std::mem::take(children);
|
||||
children.reserve(old.len());
|
||||
let mut kids = kids.into_iter().peekable();
|
||||
for (i, node) in old.into_iter().enumerate() {
|
||||
match kids.next_if(|(j, _)| *j == i) {
|
||||
Some((_, k)) => each(k, node, children),
|
||||
None => children.push(node),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The children of the node at index path `path`, or the top-level layers for an empty path.
|
||||
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
|
||||
match path {
|
||||
[] => Some(&mut self.layers),
|
||||
_ => self.node_mut_at(path)?.children_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default target layer: the topmost visible, unlocked layer.
|
||||
pub fn default_layer(&self) -> Option<NodeId> {
|
||||
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
|
||||
@@ -955,6 +1226,9 @@ impl Document {
|
||||
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
|
||||
/// Artboard).
|
||||
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
|
||||
// Art drawn flush with the artboard can end up a rounding error outside it (a background
|
||||
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
|
||||
let rect = rect.inflate(1e-6, 1e-6);
|
||||
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() {
|
||||
@@ -984,6 +1258,73 @@ impl Document {
|
||||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||||
}
|
||||
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
|
||||
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
|
||||
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
|
||||
/// visible? A guide whose layer is gone shows as one on no layer.
|
||||
pub fn guide_shown(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
|
||||
}
|
||||
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
|
||||
pub fn guide_editable(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
|
||||
}
|
||||
/// Put the ruler guides on no layer this document has (from files saved before guides had
|
||||
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
|
||||
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
|
||||
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
|
||||
let mut n = 0;
|
||||
for (g, lost) in self.guides.iter_mut().zip(lost) {
|
||||
if lost {
|
||||
g.layer = Some(layer);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
|
||||
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
|
||||
pub fn adopt_guides(&mut self) -> usize {
|
||||
self.default_layer().map_or(0, |home| self.rehome_guides(home))
|
||||
}
|
||||
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
|
||||
/// step. Guides on no layer stay. Returns how many went.
|
||||
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
|
||||
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
|
||||
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
|
||||
}
|
||||
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
|
||||
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
|
||||
/// the objects on it. Returns how many.
|
||||
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
|
||||
let layers = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if c.is_layer() {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
|
||||
let copies: Vec<Guide> = self
|
||||
.guides
|
||||
.iter()
|
||||
.filter_map(|g| {
|
||||
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
|
||||
Some(Guide { layer: Some(to), ..g.clone() })
|
||||
})
|
||||
.collect();
|
||||
let n = copies.len();
|
||||
self.guides.extend(copies);
|
||||
n
|
||||
}
|
||||
/// The ruler guides on layer `layer` (not its sublayers'), by index.
|
||||
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
|
||||
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
|
||||
}
|
||||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||||
let along = if g.vertical { p.y } else { p.x };
|
||||
@@ -1071,7 +1412,7 @@ impl Document {
|
||||
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
|
||||
kept.push(SavedSelection { name, objects });
|
||||
}
|
||||
self.saved_selections = kept;
|
||||
self.saved_selections = kept.into();
|
||||
}
|
||||
|
||||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||||
@@ -1124,6 +1465,14 @@ impl Document {
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
|
||||
pub fn unique_symbol_name(&self, base: &str) -> String {
|
||||
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1193,6 +1542,26 @@ mod tests {
|
||||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_on_artboard_tolerates_roundoff_at_each_edge() {
|
||||
let (d, a, _) = doc_with_rects();
|
||||
let roundoff = 1e-12;
|
||||
for board in [
|
||||
Rect::new(roundoff, 0.0, 10.0, 10.0),
|
||||
Rect::new(0.0, roundoff, 10.0, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff),
|
||||
] {
|
||||
assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}");
|
||||
}
|
||||
// A real overhang remains outside.
|
||||
for board in
|
||||
[Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)]
|
||||
{
|
||||
assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
@@ -1313,6 +1682,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_lookups;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
|
||||
+60
-11
@@ -72,10 +72,31 @@ pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo
|
||||
}))
|
||||
}
|
||||
|
||||
/// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]).
|
||||
const FNV_BASIS: u64 = 0xcbf29ce484222325;
|
||||
|
||||
/// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`.
|
||||
fn fnv1a(h: u64, bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
|
||||
pub fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
|
||||
format!("img{h:016x}")
|
||||
format!("img{:016x}", fnv1a(FNV_BASIS, bytes))
|
||||
}
|
||||
|
||||
/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
|
||||
pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result<String> {
|
||||
let mut buf = vec![0; 1 << 16];
|
||||
let mut h = FNV_BASIS;
|
||||
loop {
|
||||
match r.read(&mut buf) {
|
||||
Ok(0) => return Ok(format!("img{h:016x}")),
|
||||
Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
@@ -115,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
/// `n` and every descendant, opacity-mask art included.
|
||||
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &n.mask {
|
||||
if let Some(m) = &n.extras().mask {
|
||||
walk_all(&m.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -127,7 +148,12 @@ impl Document {
|
||||
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
|
||||
/// opacity-mask art included.
|
||||
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
|
||||
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
|
||||
let roots = self
|
||||
.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| &p.art));
|
||||
for root in roots {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
@@ -269,15 +295,19 @@ impl Document {
|
||||
NodeKind::PlacedDocument(placed) => f(&mut placed.link),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(mask) = &mut n.mask {
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
walk(&mut mask.art, f);
|
||||
}
|
||||
for child in n.children_mut().into_iter().flatten() {
|
||||
walk(child, f);
|
||||
}
|
||||
}
|
||||
let roots =
|
||||
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||||
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| &mut p.art));
|
||||
for root in roots {
|
||||
walk(root, &mut f);
|
||||
}
|
||||
@@ -301,15 +331,19 @@ impl Document {
|
||||
{
|
||||
f(im);
|
||||
}
|
||||
if let Some(m) = &mut n.mask {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
update(&mut m.art, pick, f);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
update(c, pick, f);
|
||||
}
|
||||
}
|
||||
let roots =
|
||||
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||||
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| &mut p.art));
|
||||
for root in roots {
|
||||
update(root, &pick, &mut f);
|
||||
}
|
||||
@@ -336,7 +370,14 @@ mod tests {
|
||||
0,
|
||||
Node::new(
|
||||
id,
|
||||
NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: key.into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::IDENTITY,
|
||||
link,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
@@ -357,6 +398,14 @@ mod tests {
|
||||
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reader_hashes_as_its_bytes_do() {
|
||||
// Several 64 KB reads, and none.
|
||||
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||||
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
|
||||
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_get_a_small_png_preview() {
|
||||
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
|
||||
|
||||
+13
-12
@@ -1222,7 +1222,7 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
|
||||
/// edit (its points and handles).
|
||||
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
|
||||
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
|
||||
match kind {
|
||||
match &**kind {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } => {
|
||||
let grid = envelope_grid(*rows, *cols, points, handles)?;
|
||||
Some((grid.lines(), Some(grid)))
|
||||
@@ -1270,7 +1270,7 @@ impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of envelope node `n` (`None` for other nodes).
|
||||
pub fn of(n: &'a Node) -> Option<Self> {
|
||||
match &n.kind {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1388,7 +1388,7 @@ fn carry_look(from: &Node, mut to: Node) -> Node {
|
||||
to.isolate = from.isolate;
|
||||
to.knockout = from.knockout;
|
||||
to.knockout_shape = from.knockout_shape;
|
||||
to.mask = from.mask.clone();
|
||||
to.extras_mut().mask = from.extras().mask.clone();
|
||||
to.appearance.effects = from.appearance.effects.clone();
|
||||
to
|
||||
}
|
||||
@@ -1417,7 +1417,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
{
|
||||
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
|
||||
}
|
||||
if let Some(m) = &mut out.mask {
|
||||
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(map_node(&m.art, w));
|
||||
}
|
||||
match &mut out.kind {
|
||||
@@ -1494,7 +1494,7 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() }));
|
||||
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
|
||||
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
|
||||
}
|
||||
}
|
||||
@@ -1523,17 +1523,18 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
|
||||
_ => None,
|
||||
};
|
||||
let plain = |items: Vec<AppearanceItem>| {
|
||||
Arc::new(Node {
|
||||
appearance: Appearance { items, ..Default::default() },
|
||||
let mut p = Node {
|
||||
appearance: Appearance { items, ..Default::default() }.into(),
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
knockout_shape: false,
|
||||
mask: None,
|
||||
name: None,
|
||||
..n.clone()
|
||||
})
|
||||
};
|
||||
p.extras_mut().mask = None;
|
||||
Arc::new(p)
|
||||
};
|
||||
let mut pieces = vec![];
|
||||
let mut rest = vec![];
|
||||
@@ -1560,7 +1561,7 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
|
||||
}
|
||||
Some(Node {
|
||||
kind: NodeKind::Group { children: pieces, clip: false },
|
||||
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() },
|
||||
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(),
|
||||
..n.clone()
|
||||
})
|
||||
}
|
||||
@@ -1640,7 +1641,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
|
||||
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => blend_expand(children, spec),
|
||||
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks),
|
||||
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
NodeKind::PlacedDocument(p) => p.art(n),
|
||||
@@ -1669,7 +1670,7 @@ pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node {
|
||||
g.knockout = crate::Knockout::Off;
|
||||
}
|
||||
g.knockout_shape = n.knockout_shape;
|
||||
g.mask = n.mask.clone();
|
||||
g.extras_mut().mask = n.extras().mask.clone();
|
||||
g
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! How much memory a document keeps that another doesn't share: what an undo step costs.
|
||||
//!
|
||||
//! Documents share structure (`Arc<Node>` children, [`crate::Shared`] resources), so an old
|
||||
//! version kept in the undo history holds only what the edits after it replaced. The estimate
|
||||
//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both
|
||||
//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass
|
||||
//! over each changed child list). One walk gives both directions: what the older version keeps
|
||||
//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo
|
||||
//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values
|
||||
//! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per
|
||||
//! allocation, without following every last field: an estimate in the right ballpark, for a
|
||||
//! memory budget, not an exact figure.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::mem::size_of;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Paint, Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, SubPath};
|
||||
|
||||
use crate::live::{BlendSpec, EnvelopeKind};
|
||||
use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef};
|
||||
use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared};
|
||||
|
||||
/// Bookkeeping and rounding a typical allocator adds to each allocation.
|
||||
const ALLOC: usize = 16;
|
||||
/// An `Arc`'s two reference counts.
|
||||
const ARC: usize = 16;
|
||||
/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a
|
||||
/// typical size of what they own besides the node.
|
||||
const OTHER_KIND: usize = 256;
|
||||
|
||||
impl Document {
|
||||
/// Roughly how many bytes of heap this document keeps that `newer` doesn't share with it: what
|
||||
/// keeping this version in the undo history costs while `newer` (a later version) is kept too.
|
||||
/// Visits only the parts that aren't shared (see the [module docs](self)).
|
||||
pub fn retained_bytes(&self, newer: &Document) -> usize {
|
||||
self.retained_bytes_each(newer).0
|
||||
}
|
||||
|
||||
/// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other`
|
||||
/// doesn't share, and what `other` keeps that this one doesn't.
|
||||
pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) {
|
||||
if std::ptr::eq(self, other) {
|
||||
return (0, 0);
|
||||
}
|
||||
let own = size_of::<Document>() + ALLOC + ARC;
|
||||
let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] };
|
||||
w.list(&self.layers, &other.layers);
|
||||
for i in 0..self.symbols.len().max(other.symbols.len()) {
|
||||
match (self.symbols.get(i), other.symbols.get(i)) {
|
||||
(Some(a), Some(b)) => w.pair(&a.art, &b.art),
|
||||
(a, b) => {
|
||||
a.into_iter().for_each(|a| w.alone(0, &a.art));
|
||||
b.into_iter().for_each(|b| w.alone(1, &b.art));
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..self.patterns.len().max(other.patterns.len()) {
|
||||
match (self.patterns.get(i), other.patterns.get(i)) {
|
||||
(Some(a), Some(b)) => w.list(&a.art, &b.art),
|
||||
(a, b) => {
|
||||
a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n));
|
||||
b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
let [mut a, mut b] = w.bytes;
|
||||
a += self.lists_bytes() + self.resources_bytes(other);
|
||||
b += other.lists_bytes() + other.resources_bytes(self);
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// The symbol and pattern lists themselves (their art is counted as nodes).
|
||||
fn lists_bytes(&self) -> usize {
|
||||
vec_bytes(&self.symbols)
|
||||
+ self.symbols.iter().map(|s| s.name.capacity()).sum::<usize>()
|
||||
+ vec_bytes(&self.patterns)
|
||||
+ self.patterns.iter().map(|p| p.name.capacity()).sum::<usize>()
|
||||
}
|
||||
|
||||
/// The document-wide lists and settings: a copy of the document copies those that aren't
|
||||
/// [`Shared`]; the shared ones count only when `newer` has its own.
|
||||
fn resources_bytes(&self, newer: &Document) -> usize {
|
||||
fn shared<T>(a: &Shared<T>, b: &Shared<T>, f: impl FnOnce(&T) -> usize) -> usize {
|
||||
if Shared::ptr_eq(a, b) { 0 } else { ALLOC + ARC + f(a) }
|
||||
}
|
||||
let mut n = 0;
|
||||
n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::<usize>();
|
||||
n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets);
|
||||
n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::<usize>();
|
||||
n += self.images.iter().map(|(k, b)| 4 * size_of::<usize>() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::<usize>();
|
||||
n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes);
|
||||
n += self.title.capacity();
|
||||
n += shared(&self.swatches, &newer.swatches, |s| swatches_bytes(s));
|
||||
n += shared(&self.swatch_groups, &newer.swatch_groups, |g| {
|
||||
vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::<usize>()
|
||||
});
|
||||
n += shared(&self.graphic_styles, &newer.graphic_styles, |g| {
|
||||
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::<usize>()
|
||||
});
|
||||
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
|
||||
n += shared(&self.char_styles, &newer.char_styles, styles);
|
||||
n += shared(&self.para_styles, &newer.para_styles, styles);
|
||||
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
|
||||
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
|
||||
});
|
||||
n += shared(&self.variables, &newer.variables, |v| {
|
||||
// Map entries: the key and value plus a share of the tree's nodes.
|
||||
let entry = 2 * size_of::<usize>();
|
||||
vec_bytes(&v.variables)
|
||||
+ v.variables.iter().map(|v| v.name.capacity()).sum::<usize>()
|
||||
+ vec_bytes(&v.datasets)
|
||||
+ v.datasets.iter().map(|d| d.name.capacity() + d.values.keys().map(|k| entry + k.capacity() + 32).sum::<usize>()).sum::<usize>()
|
||||
+ v.bindings.values().map(|b| entry + 8 + b.iter().map(|s| entry + s.capacity()).sum::<usize>()).sum::<usize>()
|
||||
});
|
||||
n += shared(&self.unknown, &newer.unknown, map_bytes);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// One estimate in progress: two documents walked side by side.
|
||||
struct Walk<'a> {
|
||||
docs: [&'a Document; 2],
|
||||
/// For each side, every node the other document holds (by address), built the first time a
|
||||
/// node of that side that the other may share has no counterpart in place: then a node moved
|
||||
/// elsewhere (grouped, say) is still found shared.
|
||||
seen: [Option<HashSet<usize>>; 2],
|
||||
/// For each side, what it keeps that the other doesn't.
|
||||
bytes: [usize; 2],
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
/// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or
|
||||
/// versions of the same object) are compared in place; the others (inserted, deleted, moved)
|
||||
/// may still be shared from elsewhere.
|
||||
fn list(&mut self, a: &[Arc<Node>], b: &[Arc<Node>]) {
|
||||
let same = |x: &Arc<Node>, y: &Arc<Node>| Arc::ptr_eq(x, y) || x.id == y.id;
|
||||
// In place from the start and from the end: an insertion or deletion shifts only what is
|
||||
// between.
|
||||
let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count();
|
||||
let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default());
|
||||
let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count();
|
||||
for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) {
|
||||
self.pair(x, y);
|
||||
}
|
||||
let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default();
|
||||
let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default();
|
||||
for i in 0..a_mid.len().max(b_mid.len()) {
|
||||
match (a_mid.get(i), b_mid.get(i)) {
|
||||
(Some(x), Some(y)) if same(x, y) => self.pair(x, y),
|
||||
(x, y) => {
|
||||
x.into_iter().for_each(|x| self.alone(0, x));
|
||||
y.into_iter().for_each(|y| self.alone(1, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`,
|
||||
/// both nodes and what under them isn't shared when they are versions of the same object.
|
||||
fn pair(&mut self, a: &Arc<Node>, b: &Arc<Node>) {
|
||||
if Arc::ptr_eq(a, b) {
|
||||
return;
|
||||
}
|
||||
if a.id != b.id {
|
||||
self.alone(0, a);
|
||||
self.alone(1, b);
|
||||
return;
|
||||
}
|
||||
self.bytes[0] += node_bytes(a, Some(b));
|
||||
self.bytes[1] += node_bytes(b, Some(a));
|
||||
match (a.children(), b.children()) {
|
||||
(Some(x), Some(y)) => self.list(x, y),
|
||||
(x, y) => {
|
||||
x.into_iter().flatten().for_each(|c| self.alone(0, c));
|
||||
y.into_iter().flatten().for_each(|c| self.alone(1, c));
|
||||
}
|
||||
}
|
||||
match (&a.extras().mask, &b.extras().mask) {
|
||||
(Some(x), Some(y)) => self.pair(&x.art, &y.art),
|
||||
(x, y) => {
|
||||
x.iter().for_each(|m| self.alone(0, &m.art));
|
||||
y.iter().for_each(|m| self.alone(1, &m.art));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Node `n` of side `side` without a counterpart in place, and what under it the other side
|
||||
/// doesn't hold.
|
||||
fn alone(&mut self, side: usize, n: &Arc<Node>) {
|
||||
if self.other_holds(side, n) {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = self.bytes.get_mut(side) {
|
||||
*b += node_bytes(n, None);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
self.alone(side, c);
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
self.alone(side, &m.art);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document across from `side` holds node `n` somewhere.
|
||||
fn other_holds(&mut self, side: usize, n: &Arc<Node>) -> bool {
|
||||
// Held only where it was found: not in the other document (which would hold it too). So
|
||||
// new art inserted by the edit is told apart without looking.
|
||||
if Arc::strong_count(n) == 1 {
|
||||
return false;
|
||||
}
|
||||
let (Some(other), Some(seen)) = (self.docs.get(1 - side.min(1)), self.seen.get_mut(side)) else { return false };
|
||||
let seen = seen.get_or_insert_with(|| {
|
||||
fn add(n: &Arc<Node>, out: &mut HashSet<usize>) {
|
||||
// A node already seen was entered already (a node shared within the document).
|
||||
if !out.insert(Arc::as_ptr(n) as usize) {
|
||||
return;
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
add(c, out);
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
add(&m.art, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashSet::new();
|
||||
let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art));
|
||||
for n in roots {
|
||||
add(n, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
seen.contains(&(Arc::as_ptr(n) as usize))
|
||||
}
|
||||
}
|
||||
|
||||
/// One node's own heap: the node, and what it owns besides its children and mask art (counted
|
||||
/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it.
|
||||
fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
fn boxed<T>(b: &Option<Box<T>>) -> usize {
|
||||
b.as_ref().map_or(0, |_| ALLOC + size_of::<T>())
|
||||
}
|
||||
let mut b = ALLOC + ARC + size_of::<Node>();
|
||||
b += n.name.as_ref().map_or(0, String::capacity);
|
||||
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
|
||||
b += ALLOC + ARC + appearance_heap(&n.appearance);
|
||||
}
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
}
|
||||
b += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
|
||||
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
|
||||
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
|
||||
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
|
||||
NodeKind::Text(t) => ALLOC + text_bytes(t),
|
||||
NodeKind::Image(im) => ALLOC + size_of::<ImageObject>() + im.key.capacity(),
|
||||
NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(),
|
||||
_ => OTHER_KIND,
|
||||
};
|
||||
b
|
||||
}
|
||||
|
||||
fn path_bytes(p: &PathData) -> usize {
|
||||
let anchors: usize =
|
||||
p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::<Anchor>() }).sum();
|
||||
anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::<SubPath>() }
|
||||
}
|
||||
|
||||
fn text_bytes(t: &TextObject) -> usize {
|
||||
size_of::<TextObject>()
|
||||
+ vec_bytes(&t.runs)
|
||||
+ t.runs.iter().map(|r: &TextRun| r.text.capacity()).sum::<usize>()
|
||||
+ t.paras.capacity() * size_of::<ParaStyle>()
|
||||
+ vec_bytes(&t.wrap)
|
||||
+ vec_bytes(&t.cached_baselines)
|
||||
}
|
||||
|
||||
/// The heap an appearance owns (not the appearance itself).
|
||||
fn appearance_heap(a: &Appearance) -> usize {
|
||||
size_of::<Appearance>()
|
||||
+ vec_bytes(&a.items)
|
||||
+ a.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
AppearanceItem::Fill(f) => paint_heap(&f.paint),
|
||||
AppearanceItem::Stroke(s) => paint_heap(&s.paint),
|
||||
})
|
||||
.sum::<usize>()
|
||||
+ vec_bytes(&a.effects)
|
||||
+ a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
|
||||
}
|
||||
|
||||
fn paint_heap(p: &Paint) -> usize {
|
||||
match p {
|
||||
Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity),
|
||||
Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops),
|
||||
Paint::Pattern { pattern, .. } => pattern.capacity(),
|
||||
Paint::None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn swatches_bytes(s: &[Swatch]) -> usize {
|
||||
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::<usize>()
|
||||
}
|
||||
|
||||
/// A vector's buffer (not what its elements own).
|
||||
fn vec_bytes<T>(v: &Vec<T>) -> usize {
|
||||
if v.capacity() == 0 { 0 } else { ALLOC + v.capacity() * size_of::<T>() }
|
||||
}
|
||||
|
||||
fn map_bytes(m: &serde_json::Map<String, Value>) -> usize {
|
||||
m.iter().map(|(k, v)| ALLOC + 4 * size_of::<usize>() + k.capacity() + size_of::<Value>() + json_bytes(v)).sum()
|
||||
}
|
||||
|
||||
/// The heap a JSON value owns.
|
||||
fn json_bytes(v: &Value) -> usize {
|
||||
match v {
|
||||
Value::String(s) => s.capacity(),
|
||||
Value::Array(a) => vec_bytes(a) + a.iter().map(json_bytes).sum::<usize>(),
|
||||
Value::Object(m) => map_bytes(m),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn doc_with_rects(n: usize) -> Document {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let layer = d.layers[0].id;
|
||||
for i in 0..n {
|
||||
let id = d.alloc_id();
|
||||
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art())).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_copy_keeps_nothing_of_its_own() {
|
||||
let d = doc_with_rects(10);
|
||||
let copy = d.clone();
|
||||
// Only the document itself and its own lists: no node, no shared resource.
|
||||
assert!(d.retained_bytes(©) < 2048, "{}", d.retained_bytes(©));
|
||||
assert_eq!(d.retained_bytes(&d), 0);
|
||||
}
|
||||
|
||||
/// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared).
|
||||
/// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490
|
||||
/// once nodes kept their rarely set parts out of line.
|
||||
#[test]
|
||||
fn moving_rectangles_costs_their_nodes() {
|
||||
let n = 1000;
|
||||
let before = doc_with_rects(n);
|
||||
let mut after = before.clone();
|
||||
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let node = after.node_mut(id).unwrap();
|
||||
if let NodeKind::Path { path, .. } = &mut node.kind {
|
||||
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
|
||||
a.p.x += 5.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let per = before.retained_bytes(&after) / n;
|
||||
assert!((400..1200).contains(&per), "{per} bytes per moved rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_object_is_found_shared() {
|
||||
let before = doc_with_rects(100);
|
||||
let mut after = before.clone();
|
||||
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
// Put the rectangles in a group (the same Arcs, elsewhere).
|
||||
let gid = after.alloc_id();
|
||||
let layer = Arc::make_mut(&mut after.layers[0]);
|
||||
let kids = std::mem::take(layer.children_mut().unwrap());
|
||||
layer.children_mut().unwrap().push(Arc::new(Node::group(gid, kids)));
|
||||
assert_eq!(ids.len(), 100);
|
||||
let bytes = before.retained_bytes(&after);
|
||||
assert!(bytes < 8192, "only the old layer and its child list: {bytes}");
|
||||
}
|
||||
|
||||
/// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion
|
||||
/// cost the side that has the objects.
|
||||
#[test]
|
||||
fn both_ways_at_once() {
|
||||
let before = doc_with_rects(100);
|
||||
let mut after = before.clone();
|
||||
let layer = after.layers[0].id;
|
||||
for at in [0, 50, usize::MAX] {
|
||||
let id = after.alloc_id();
|
||||
let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap();
|
||||
}
|
||||
let (kept, added) = before.retained_bytes_each(&after);
|
||||
assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before)));
|
||||
assert_eq!(after.retained_bytes_each(&before), (added, kept));
|
||||
// The old layer and its list are the old version's own; the new one holds three new nodes.
|
||||
assert!(kept < 4096, "{kept}");
|
||||
assert!(added > kept + 3 * 300, "{added} for three new rectangles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resource_counts_only_when_changed() {
|
||||
let before = doc_with_rects(1);
|
||||
let mut after = before.clone();
|
||||
let same = before.retained_bytes(&after);
|
||||
after.swatches.push(before.swatches[1].clone());
|
||||
assert!(before.retained_bytes(&after) > same + 1000, "the old swatch list is the old version's own");
|
||||
}
|
||||
}
|
||||
+653
-80
@@ -394,7 +394,9 @@ pub struct ImageObject {
|
||||
pub placement: crate::links::PlacementOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// Written with its `type` first, then its fields (an internally tagged enum); read by
|
||||
/// [`kind_de`], which takes the fields straight from the file when `type` comes first.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum NodeKind {
|
||||
/// A top-level layer or sublayer.
|
||||
@@ -427,8 +429,9 @@ pub enum NodeKind {
|
||||
path: PathData,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
rule: FillRule,
|
||||
/// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
live: Option<LiveShape>,
|
||||
live: Option<Box<LiveShape>>,
|
||||
/// This path acts as a clipping path inside a clip group.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
clipping: bool,
|
||||
@@ -443,7 +446,8 @@ pub enum NodeKind {
|
||||
rule: FillRule,
|
||||
},
|
||||
Text(Box<TextObject>),
|
||||
Image(ImageObject),
|
||||
/// Boxed, as the other large kinds: a node is as large as its largest kind.
|
||||
Image(Box<ImageObject>),
|
||||
SymbolInstance {
|
||||
symbol: String,
|
||||
xf: Affine,
|
||||
@@ -453,12 +457,12 @@ pub enum NodeKind {
|
||||
Blend {
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
spec: BlendSpec,
|
||||
spec: Box<BlendSpec>,
|
||||
},
|
||||
/// Live envelope distortion of `content`.
|
||||
Envelope {
|
||||
content: Vec<Arc<Node>>,
|
||||
kind: EnvelopeKind,
|
||||
kind: Box<EnvelopeKind>,
|
||||
/// Envelope Options → Fidelity (0–100).
|
||||
#[serde(default = "crate::live::default_fidelity")]
|
||||
fidelity: f64,
|
||||
@@ -472,7 +476,7 @@ pub enum NodeKind {
|
||||
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
|
||||
/// bounds in this frame, and a warp bends along it.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
frame: Affine,
|
||||
frame: Box<Affine>,
|
||||
},
|
||||
/// Gradient mesh.
|
||||
Mesh(GradientMesh),
|
||||
@@ -491,72 +495,328 @@ fn one() -> f32 {
|
||||
}
|
||||
|
||||
/// One object in the tree. Children are `Arc`s so documents share structure between undo states.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
///
|
||||
/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for
|
||||
/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a
|
||||
/// small node is a cheap step. Files keep the flat field list either way: a node is written field
|
||||
/// by field from where each lives, and read through a flat `NodeWire`.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
#[serde(from = "NodeWire")]
|
||||
pub struct Node {
|
||||
pub id: NodeId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub locked: bool,
|
||||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub blend: BlendMode,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub isolate: bool,
|
||||
/// Knockout Group: whether a container's children knock each other out (see [`Knockout`]).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub knockout: Knockout,
|
||||
/// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and
|
||||
/// opacity mask scale how much of the objects below it in the group it knocks out.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub knockout_shape: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub appearance: Appearance,
|
||||
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mask: Option<Box<OpacityMask>>,
|
||||
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
|
||||
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<Box<serde_json::Value>>,
|
||||
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wrap: Option<crate::text::TextWrap>,
|
||||
/// Graph object: the group's children are generated from this spec (Object → Graph).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
/// Index of the graph series this generated group belongs to. Axes, the legend and other
|
||||
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub series_index: Option<u32>,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
pub appearance: crate::Shared<Appearance>,
|
||||
pub kind: NodeKind,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
/// that style's look: editing its appearance or transparency breaks the link.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graphic_style: Option<u32>,
|
||||
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attrs: Option<Box<ObjectAttributes>>,
|
||||
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
|
||||
/// these options.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
|
||||
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
|
||||
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bbox_angle: f64,
|
||||
/// The rarely set parts, read with [`Node::extras`] and changed with [`Node::extras_mut`]
|
||||
/// (public so that `Node { opacity: 1.0, ..node }` works).
|
||||
pub extras: Extras,
|
||||
}
|
||||
|
||||
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
|
||||
/// them stay small ([`Node::extras`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NodeExtras {
|
||||
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
|
||||
pub mask: Option<Box<OpacityMask>>,
|
||||
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
|
||||
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
|
||||
pub trace: Option<Box<serde_json::Value>>,
|
||||
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
|
||||
pub wrap: Option<crate::text::TextWrap>,
|
||||
/// Graph object: the group's children are generated from this spec (Object → Graph).
|
||||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
/// Index of the graph series this generated group belongs to. Axes, the legend and other
|
||||
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
|
||||
pub series_index: Option<u32>,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
/// that style's look: editing its appearance or transparency breaks the link.
|
||||
pub graphic_style: Option<u32>,
|
||||
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
|
||||
pub attrs: Option<Box<ObjectAttributes>>,
|
||||
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
|
||||
/// these options.
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// Object › Perspective: the perspective grid plane the object is attached to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
series_index: None,
|
||||
shaper: None,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
perspective: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
/// none.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Extras(Option<Box<NodeExtras>>);
|
||||
|
||||
impl Extras {
|
||||
fn get(&self) -> &NodeExtras {
|
||||
self.0.as_deref().unwrap_or(&NO_EXTRAS)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Extras {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (&self.0, &other.0) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => self.get() == other.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The rarely set parts of this node (all unset when it has none).
|
||||
#[inline]
|
||||
pub fn extras(&self) -> &NodeExtras {
|
||||
self.extras.get()
|
||||
}
|
||||
/// The rarely set parts of this node, to change: when the change is done (the guard dropped),
|
||||
/// extras that were set are allocated and extras left all unset are let go. Setting one to
|
||||
/// `None` on a node without extras allocates nothing.
|
||||
#[inline]
|
||||
pub fn extras_mut(&mut self) -> ExtrasMut<'_> {
|
||||
ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() }
|
||||
}
|
||||
/// The rarely set parts of this node, if it has any: to change what is there in place.
|
||||
#[inline]
|
||||
pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> {
|
||||
self.extras.0.as_deref_mut()
|
||||
}
|
||||
/// The live shape of a path object, if it is one.
|
||||
pub fn live_shape(&self) -> Option<&LiveShape> {
|
||||
match &self.kind {
|
||||
NodeKind::Path { live, .. } => live.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The live shape of a path object, if it is one, to change.
|
||||
pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> {
|
||||
match &mut self.kind {
|
||||
NodeKind::Path { live, .. } => live.as_deref_mut(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// This node with opacity mask `mask` (or none).
|
||||
pub fn with_mask(mut self, mask: Option<Box<OpacityMask>>) -> Self {
|
||||
self.extras_mut().mask = mask;
|
||||
self
|
||||
}
|
||||
/// The extras, if this node has them allocated (for [`crate::mem`]'s estimate).
|
||||
pub(crate) fn extras_box(&self) -> Option<&NodeExtras> {
|
||||
self.extras.0.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Node::extras_mut`]: the node's extras while they change.
|
||||
pub struct ExtrasMut<'a> {
|
||||
slot: &'a mut Option<Box<NodeExtras>>,
|
||||
/// Stands for the extras of a node without any, boxed into it when dropped if one was set.
|
||||
local: NodeExtras,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ExtrasMut<'_> {
|
||||
type Target = NodeExtras;
|
||||
fn deref(&self) -> &NodeExtras {
|
||||
self.slot.as_deref().unwrap_or(&self.local)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for ExtrasMut<'_> {
|
||||
fn deref_mut(&mut self) -> &mut NodeExtras {
|
||||
match self.slot.as_deref_mut() {
|
||||
Some(e) => e,
|
||||
None => &mut self.local,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExtrasMut<'_> {
|
||||
fn drop(&mut self) {
|
||||
match self.slot.as_deref() {
|
||||
Some(e) if *e == NO_EXTRAS => *self.slot = None,
|
||||
Some(_) => {}
|
||||
None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
|
||||
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
|
||||
impl Serialize for Node {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use crate::skip::{is_default, is_one, is_true};
|
||||
use serde::ser::SerializeStruct;
|
||||
let e = self.extras();
|
||||
// As the derive did: the fields written, then each field written or skipped in order.
|
||||
let skips = [
|
||||
false,
|
||||
self.name.is_none(),
|
||||
is_true(&self.visible),
|
||||
is_default(&self.locked),
|
||||
is_one(&self.opacity),
|
||||
is_default(&self.blend),
|
||||
is_default(&self.isolate),
|
||||
is_default(&self.knockout),
|
||||
is_default(&self.knockout_shape),
|
||||
is_default(&self.appearance),
|
||||
e.mask.is_none(),
|
||||
e.trace.is_none(),
|
||||
e.wrap.is_none(),
|
||||
e.graph.is_none(),
|
||||
e.series_index.is_none(),
|
||||
e.shaper.is_none(),
|
||||
false,
|
||||
e.graphic_style.is_none(),
|
||||
e.attrs.is_none(),
|
||||
e.slice.is_none(),
|
||||
is_default(&self.bbox_angle),
|
||||
e.perspective.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
macro_rules! field {
|
||||
($name:literal, $v:expr) => {
|
||||
if skip.next().unwrap_or(false) {
|
||||
st.skip_field($name)?;
|
||||
} else {
|
||||
st.serialize_field($name, $v)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
field!("id", &self.id);
|
||||
field!("name", &self.name);
|
||||
field!("visible", &self.visible);
|
||||
field!("locked", &self.locked);
|
||||
field!("opacity", &self.opacity);
|
||||
field!("blend", &self.blend);
|
||||
field!("isolate", &self.isolate);
|
||||
field!("knockout", &self.knockout);
|
||||
field!("knockout_shape", &self.knockout_shape);
|
||||
field!("appearance", &self.appearance);
|
||||
field!("mask", &e.mask);
|
||||
field!("trace", &e.trace);
|
||||
field!("wrap", &e.wrap);
|
||||
field!("graph", &e.graph);
|
||||
field!("series_index", &e.series_index);
|
||||
field!("shaper", &e.shaper);
|
||||
field!("kind", &self.kind);
|
||||
field!("graphic_style", &e.graphic_style);
|
||||
field!("attrs", &e.attrs);
|
||||
field!("slice", &e.slice);
|
||||
field!("bbox_angle", &self.bbox_angle);
|
||||
field!("perspective", &e.perspective);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
|
||||
/// (and its extras, allocated only when one is set) without a copy of what they own.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename = "Node")]
|
||||
struct NodeWire {
|
||||
id: NodeId,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default = "yes")]
|
||||
visible: bool,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
#[serde(default = "one")]
|
||||
opacity: f32,
|
||||
#[serde(default)]
|
||||
blend: BlendMode,
|
||||
#[serde(default)]
|
||||
isolate: bool,
|
||||
#[serde(default)]
|
||||
knockout: Knockout,
|
||||
#[serde(default)]
|
||||
knockout_shape: bool,
|
||||
#[serde(default)]
|
||||
appearance: crate::Shared<Appearance>,
|
||||
#[serde(default)]
|
||||
mask: Option<Box<OpacityMask>>,
|
||||
#[serde(default)]
|
||||
trace: Option<Box<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
wrap: Option<crate::text::TextWrap>,
|
||||
#[serde(default)]
|
||||
graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
#[serde(default)]
|
||||
series_index: Option<u32>,
|
||||
#[serde(default)]
|
||||
shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
kind: NodeKind,
|
||||
#[serde(default)]
|
||||
graphic_style: Option<u32>,
|
||||
#[serde(default)]
|
||||
attrs: Option<Box<ObjectAttributes>>,
|
||||
#[serde(default)]
|
||||
slice: Option<Box<crate::SliceOptions>>,
|
||||
#[serde(default)]
|
||||
bbox_angle: f64,
|
||||
#[serde(default)]
|
||||
perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
fn from(w: NodeWire) -> Self {
|
||||
let extras = NodeExtras {
|
||||
mask: w.mask,
|
||||
trace: w.trace,
|
||||
wrap: w.wrap,
|
||||
graph: w.graph,
|
||||
series_index: w.series_index,
|
||||
shaper: w.shaper,
|
||||
graphic_style: w.graphic_style,
|
||||
attrs: w.attrs,
|
||||
slice: w.slice,
|
||||
perspective: w.perspective,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
visible: w.visible,
|
||||
locked: w.locked,
|
||||
opacity: w.opacity,
|
||||
blend: w.blend,
|
||||
isolate: w.isolate,
|
||||
knockout: w.knockout,
|
||||
knockout_shape: w.knockout_shape,
|
||||
appearance: w.appearance,
|
||||
kind: w.kind,
|
||||
bbox_angle: w.bbox_angle,
|
||||
extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpacityMask {
|
||||
@@ -594,24 +854,15 @@ impl Node {
|
||||
isolate: false,
|
||||
knockout: Knockout::Neutral,
|
||||
knockout_shape: false,
|
||||
appearance: Appearance::default(),
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
series_index: None,
|
||||
shaper: None,
|
||||
appearance: Appearance::default().into(),
|
||||
kind,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
bbox_angle: 0.0,
|
||||
perspective: None,
|
||||
extras: Extras::default(),
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
|
||||
n.appearance = appearance;
|
||||
n.appearance = appearance.into();
|
||||
n
|
||||
}
|
||||
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
|
||||
@@ -691,7 +942,7 @@ impl Node {
|
||||
&& !self.isolate
|
||||
&& self.knockout == Knockout::Neutral
|
||||
&& !self.knockout_shape
|
||||
&& self.mask.is_none()
|
||||
&& self.extras().mask.is_none()
|
||||
}
|
||||
/// Kind name as the Layers panel / Properties panel shows it.
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
@@ -772,7 +1023,7 @@ impl Node {
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||||
.iter()
|
||||
.skip(usize::from(self.shaper.is_some()))
|
||||
.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_bounds())),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
@@ -782,7 +1033,7 @@ impl Node {
|
||||
let b = crate::live::nodes_bounds(children);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
}
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
NodeKind::PlacedDocument(p) => Some(p.bounds()),
|
||||
@@ -806,7 +1057,7 @@ impl Node {
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.shaper.is_some()))
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
@@ -854,7 +1105,11 @@ impl Node {
|
||||
self.appearance.scale_effects(k, f);
|
||||
}
|
||||
}
|
||||
self.appearance.transform_gradients(a);
|
||||
// Only through `DerefMut` when there is something to change: the appearance is shared
|
||||
// copy-on-write (`Shared`), and a move must not copy what it leaves as it is.
|
||||
if self.appearance.gradients().next().is_some() {
|
||||
self.appearance.transform_gradients(a);
|
||||
}
|
||||
if sc.patterns {
|
||||
crate::pattern::transform_pattern_paints(self, a);
|
||||
}
|
||||
@@ -899,8 +1154,8 @@ impl Node {
|
||||
for c in content.iter_mut() {
|
||||
Arc::make_mut(c).transform_scaled(a, sc);
|
||||
}
|
||||
*frame = crate::live::envelope_frame(a * *frame);
|
||||
match kind {
|
||||
**frame = crate::live::envelope_frame(a * **frame);
|
||||
match &mut **kind {
|
||||
EnvelopeKind::Mesh { points, handles, .. } => {
|
||||
for p in points.iter_mut() {
|
||||
*p = a * *p;
|
||||
@@ -919,7 +1174,7 @@ impl Node {
|
||||
NodeKind::Mesh(m) => m.transform(a),
|
||||
NodeKind::Repeat(r) => r.transform(a, *sc),
|
||||
}
|
||||
if let Some(m) = &mut self.mask
|
||||
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& m.linked
|
||||
{
|
||||
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
|
||||
@@ -1035,7 +1290,7 @@ impl Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
|
||||
for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) {
|
||||
c.push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
@@ -1320,25 +1575,25 @@ impl ImageMap {
|
||||
impl Node {
|
||||
/// The URL this object links to, if any.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
|
||||
self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the canvas shows this object's centre point when it is selected: as set in the
|
||||
/// Attributes panel, else [`Self::shows_center_by_default`].
|
||||
pub fn shows_center(&self) -> bool {
|
||||
self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
|
||||
self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
|
||||
}
|
||||
|
||||
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
|
||||
pub fn shows_center_by_default(&self) -> bool {
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. }))
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. }))
|
||||
}
|
||||
|
||||
/// Change this object's attributes with `f`; all-default attributes are dropped.
|
||||
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
|
||||
let mut a = self.attrs.take().unwrap_or_default();
|
||||
let mut a = self.extras_mut().attrs.take().unwrap_or_default();
|
||||
let r = f(&mut a);
|
||||
self.attrs = (*a != ObjectAttributes::default()).then_some(a);
|
||||
self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a);
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -1354,7 +1609,7 @@ impl Node {
|
||||
self.visible
|
||||
&& (self.opacity < 1.0
|
||||
|| self.blend != BlendMode::Normal
|
||||
|| self.mask.as_ref().is_some_and(|m| !m.disabled)
|
||||
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|
||||
|| effects(&self.appearance.effects)
|
||||
|| self
|
||||
.appearance
|
||||
@@ -1381,10 +1636,281 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every
|
||||
/// object inside a group, again at each level of nesting) before reading it, since `type` may come
|
||||
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
|
||||
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
|
||||
/// fields before `type` are buffered, and only those.
|
||||
mod kind_de {
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::de::value::{MapAccessDeserializer, StringDeserializer};
|
||||
use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor};
|
||||
use serde::{Deserialize, forward_to_deserialize_any};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData};
|
||||
|
||||
use super::{LayerColor, LiveShape, Node, NodeKind, yes};
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes
|
||||
/// note (the compiler checks that every field is here), read externally tagged: the kind from
|
||||
/// `type`, its fields from the rest of the object ([`Tagged`]).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(remote = "NodeKind", rename_all = "lowercase")]
|
||||
enum KindDef {
|
||||
Layer {
|
||||
color: LayerColor,
|
||||
#[serde(default)]
|
||||
template: bool,
|
||||
#[serde(default = "yes")]
|
||||
printable: bool,
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
clip: bool,
|
||||
#[serde(default = "yes")]
|
||||
preview: bool,
|
||||
#[serde(default)]
|
||||
dim_images: Option<u8>,
|
||||
},
|
||||
Group {
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
clip: bool,
|
||||
},
|
||||
Path {
|
||||
path: PathData,
|
||||
#[serde(default)]
|
||||
rule: FillRule,
|
||||
#[serde(default)]
|
||||
live: Option<Box<LiveShape>>,
|
||||
#[serde(default)]
|
||||
clipping: bool,
|
||||
#[serde(default)]
|
||||
guide: bool,
|
||||
},
|
||||
Compound {
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
rule: FillRule,
|
||||
},
|
||||
Text(Box<TextObject>),
|
||||
Image(Box<super::ImageObject>),
|
||||
SymbolInstance {
|
||||
symbol: String,
|
||||
xf: Affine,
|
||||
},
|
||||
Blend {
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
spec: Box<BlendSpec>,
|
||||
},
|
||||
Envelope {
|
||||
content: Vec<Arc<Node>>,
|
||||
kind: Box<EnvelopeKind>,
|
||||
#[serde(default = "crate::live::default_fidelity")]
|
||||
fidelity: f64,
|
||||
#[serde(default)]
|
||||
editing: bool,
|
||||
#[serde(default)]
|
||||
options: crate::live::EnvelopeOptions,
|
||||
#[serde(default)]
|
||||
frame: Box<Affine>,
|
||||
},
|
||||
Mesh(GradientMesh),
|
||||
Repeat(RepeatSpec),
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NodeKind {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_map(KindVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct KindVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for KindVisitor {
|
||||
type Value = NodeKind;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("an object kind (a map with `type`)")
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<NodeKind, A::Error> {
|
||||
let mut before = vec![];
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if key == "type" {
|
||||
let tag: String = map.next_value()?;
|
||||
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
|
||||
}
|
||||
before.push((key, map.next_value::<serde_json::Value>()?));
|
||||
}
|
||||
Err(de::Error::missing_field("type"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields read before `type` (buffered), then the rest of the object.
|
||||
struct Buffered<A> {
|
||||
before: std::vec::IntoIter<(String, serde_json::Value)>,
|
||||
value: Option<serde_json::Value>,
|
||||
map: A,
|
||||
}
|
||||
|
||||
impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered<A> {
|
||||
type Error = A::Error;
|
||||
|
||||
fn next_key_seed<K: DeserializeSeed<'de>>(&mut self, seed: K) -> Result<Option<K::Value>, A::Error> {
|
||||
match self.before.next() {
|
||||
Some((key, value)) => {
|
||||
self.value = Some(value);
|
||||
seed.deserialize(StringDeserializer::<A::Error>::new(key)).map(Some)
|
||||
}
|
||||
None => self.map.next_key_seed(seed),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
|
||||
match self.value.take() {
|
||||
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
|
||||
None => self.map.next_value_seed(seed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`.
|
||||
struct Tagged<A> {
|
||||
tag: String,
|
||||
map: A,
|
||||
}
|
||||
|
||||
impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged<A> {
|
||||
type Error = A::Error;
|
||||
|
||||
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, A::Error> {
|
||||
visitor.visit_enum(self)
|
||||
}
|
||||
|
||||
forward_to_deserialize_any! {
|
||||
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
|
||||
option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum
|
||||
identifier ignored_any
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged<A> {
|
||||
type Error = A::Error;
|
||||
type Variant = Fields<A>;
|
||||
|
||||
fn variant_seed<V: DeserializeSeed<'de>>(self, seed: V) -> Result<(V::Value, Fields<A>), A::Error> {
|
||||
let kind = seed.deserialize(self.tag.into_deserializer())?;
|
||||
Ok((kind, Fields(self.map)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind's fields: the rest of the object.
|
||||
struct Fields<A>(A);
|
||||
|
||||
impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields<A> {
|
||||
type Error = A::Error;
|
||||
|
||||
fn unit_variant(self) -> Result<(), A::Error> {
|
||||
Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields"))
|
||||
}
|
||||
|
||||
fn newtype_variant_seed<T: DeserializeSeed<'de>>(self, seed: T) -> Result<T::Value, A::Error> {
|
||||
seed.deserialize(MapAccessDeserializer::new(self.0))
|
||||
}
|
||||
|
||||
fn tuple_variant<V: Visitor<'de>>(self, _: usize, _: V) -> Result<V::Value, A::Error> {
|
||||
Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields"))
|
||||
}
|
||||
|
||||
fn struct_variant<V: Visitor<'de>>(self, _: &'static [&'static str], visitor: V) -> Result<V::Value, A::Error> {
|
||||
visitor.visit_map(self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A node writes its rarely set parts among the others, in the file's field order, and reads
|
||||
/// them back into its extras; a node without any keeps none.
|
||||
#[test]
|
||||
fn extras_are_written_flat_in_order() {
|
||||
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
n.name = Some("n".into());
|
||||
n.knockout_shape = true;
|
||||
n.bbox_angle = 30.0;
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
|
||||
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
|
||||
e.series_index = Some(2);
|
||||
e.graphic_style = Some(3);
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
impl<'de> Deserialize<'de> for Keys {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct V;
|
||||
impl<'de> serde::de::Visitor<'de> for V {
|
||||
type Value = Keys;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("an object")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Keys, A::Error> {
|
||||
let mut out = vec![];
|
||||
while let Some(k) = a.next_key::<String>()? {
|
||||
a.next_value::<serde::de::IgnoredAny>()?;
|
||||
out.push(k);
|
||||
}
|
||||
Ok(Keys(out))
|
||||
}
|
||||
}
|
||||
d.deserialize_map(V)
|
||||
}
|
||||
}
|
||||
let text = serde_json::to_string(&n).unwrap();
|
||||
let keys = serde_json::from_str::<Keys>(&text).unwrap().0;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let order = [
|
||||
"id",
|
||||
"name",
|
||||
"knockout_shape",
|
||||
"appearance",
|
||||
"mask",
|
||||
"trace",
|
||||
"series_index",
|
||||
"kind",
|
||||
"graphic_style",
|
||||
"attrs",
|
||||
"slice",
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
assert!(back.extras_box().is_none());
|
||||
// Clearing the last extra lets them go; clearing one a node doesn't have allocates none.
|
||||
n.extras_mut().mask = None;
|
||||
assert!(n.extras_box().is_some());
|
||||
*n.extras_mut() = NodeExtras::default();
|
||||
assert!(n.extras_box().is_none());
|
||||
n.extras_mut().mask = None;
|
||||
assert!(n.extras_box().is_none());
|
||||
assert_eq!(n.extras(), &NodeExtras::default());
|
||||
}
|
||||
|
||||
#[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)) };
|
||||
@@ -1450,13 +1976,14 @@ mod tests {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), 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(live);
|
||||
*slot = Some(Box::new(live));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") };
|
||||
let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") };
|
||||
let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") };
|
||||
(*w, *h, *radii, *xf)
|
||||
}
|
||||
|
||||
@@ -1591,7 +2118,7 @@ mod tests {
|
||||
|n| n.knockout = Knockout::On,
|
||||
|n| n.knockout = Knockout::Off,
|
||||
|n| n.knockout_shape = true,
|
||||
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
|
||||
|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
|
||||
];
|
||||
for tweak in tweaks {
|
||||
let mut n = p();
|
||||
@@ -1647,4 +2174,50 @@ mod tests {
|
||||
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
|
||||
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
|
||||
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
|
||||
Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_read_back_with_type_first_or_anywhere() {
|
||||
let n = nested_group();
|
||||
let json = serde_json::to_string(&n).unwrap();
|
||||
assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}");
|
||||
assert_eq!(serde_json::from_str::<Node>(&json).unwrap(), n);
|
||||
// Through a JSON value, whose keys are sorted: `type` comes after `children`.
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Node>(v.clone()).unwrap(), n);
|
||||
assert_eq!(serde_json::from_str::<Node>(&v.to_string()).unwrap(), n);
|
||||
// `type` last, in the middle, and keys a newer version added (ignored, as before).
|
||||
let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#;
|
||||
assert_eq!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Group { children: vec![], clip: true });
|
||||
let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<NodeKind>(kind).unwrap(),
|
||||
NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) }
|
||||
);
|
||||
let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#;
|
||||
assert!(matches!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3));
|
||||
// Defaults as before.
|
||||
let NodeKind::Layer { printable, preview, template, .. } =
|
||||
serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap()
|
||||
else {
|
||||
panic!("a layer")
|
||||
};
|
||||
assert!(printable && preview && !template);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_kinds_are_errors() {
|
||||
for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] {
|
||||
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,8 +341,25 @@ pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Transform Patterns without Transform Objects: `m` moves the pattern tiles of `n` and of
|
||||
/// everything inside it, and the art stays where it is.
|
||||
pub fn transform_patterns_only(n: &mut Node, m: Affine) {
|
||||
transform_pattern_paints(n, m);
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
transform_patterns_only(Arc::make_mut(c), m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
|
||||
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
|
||||
// Without pattern paints there is nothing to change: don't copy the shared appearance.
|
||||
let patterned = |it: &AppearanceItem| {
|
||||
matches!(it, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Pattern { .. }))
|
||||
|| matches!(it, AppearanceItem::Stroke(s) if matches!(s.paint, Paint::Pattern { .. }))
|
||||
};
|
||||
if !n.appearance.items.iter().any(patterned) {
|
||||
return;
|
||||
}
|
||||
for it in n.appearance.items.iter_mut() {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Node {
|
||||
/// The projective map type or a symbol instance in perspective is drawn through (not while
|
||||
/// Edit Text shows it flat).
|
||||
pub fn projection(&self) -> Option<Homography> {
|
||||
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
|
||||
let a = self.extras().perspective.as_deref().filter(|a| !a.editing)?;
|
||||
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ impl Node {
|
||||
|
||||
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
|
||||
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
|
||||
if let Some(p) = self.perspective.as_deref_mut()
|
||||
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
|
||||
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
|
||||
{
|
||||
p.projection = Some(h.to_array());
|
||||
|
||||
@@ -119,7 +119,7 @@ fn prefix(key: &str) -> String {
|
||||
/// `n` and every descendant, opacity-mask art included.
|
||||
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &n.mask {
|
||||
if let Some(m) = &n.extras().mask {
|
||||
walk_all(&m.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -129,11 +129,19 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
|
||||
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
|
||||
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
|
||||
d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art))
|
||||
d.layers
|
||||
.iter()
|
||||
.chain(d.symbols.iter().map(|s| &s.art))
|
||||
.chain(d.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(d.patterns.iter().flat_map(|p| &p.art))
|
||||
}
|
||||
|
||||
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
|
||||
d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
|
||||
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))
|
||||
.chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -176,7 +184,7 @@ impl Document {
|
||||
}
|
||||
// The art shown may hold placed documents too.
|
||||
let n = Arc::make_mut(n);
|
||||
if let Some(m) = &mut n.mask {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
update(&mut m.art);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
@@ -202,11 +210,11 @@ impl Document {
|
||||
&& keys.contains(&p.key)
|
||||
{
|
||||
if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) {
|
||||
n.kind = NodeKind::Image(im);
|
||||
n.kind = NodeKind::Image(Box::new(im));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(m) = &mut n.mask {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
update(&mut m.art, keys, images);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
@@ -398,7 +406,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocu
|
||||
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
|
||||
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
|
||||
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
|
||||
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false });
|
||||
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(Box::new(im))))], clip: false });
|
||||
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
|
||||
cache().insert(e.clone());
|
||||
Some(e)
|
||||
@@ -424,7 +432,7 @@ fn art_id() -> crate::NodeId {
|
||||
/// Visit `n` and every descendant (opacity-mask art included) mutably.
|
||||
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &mut n.mask {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
each_mut(Arc::make_mut(&mut m.art), f);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
@@ -495,7 +503,10 @@ impl Document {
|
||||
let prefix = prefix(&p.key);
|
||||
add_resources(&mut d, &e.doc, &prefix);
|
||||
// The placed documents in that art, under their names here.
|
||||
let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art));
|
||||
let inner = std::iter::once(&e.art)
|
||||
.chain(e.doc.symbols.iter().map(|s| &s.art))
|
||||
.chain(e.doc.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(e.doc.patterns.iter().flat_map(|p| &p.art));
|
||||
for root in inner {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::PlacedDocument(q) = &n.kind {
|
||||
@@ -579,7 +590,14 @@ mod tests {
|
||||
d.layers.clear();
|
||||
let art = Node::new(
|
||||
NodeId(50),
|
||||
NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
|
||||
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
|
||||
|
||||
+96
-67
@@ -1,22 +1,31 @@
|
||||
//! Selection state (objects and, for direct selection, individual anchors; ruler guides).
|
||||
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Document, NodeId};
|
||||
use crate::{Document, NodeId, NodeKind};
|
||||
|
||||
/// (subpath index, anchor index) inside a path.
|
||||
pub type AnchorRef = (usize, usize);
|
||||
/// (subpath index, segment index) inside a path.
|
||||
pub type SegmentRef = (usize, usize);
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Selection {
|
||||
/// Selected objects in selection order.
|
||||
pub objects: Vec<NodeId>,
|
||||
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
|
||||
/// Direct-selected anchors, or a selected segment's endpoints used for movement. A path in
|
||||
/// `objects` with no entry here is fully selected; `segments` distinguishes segment picks.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
||||
/// Key object for Align.
|
||||
/// Explicitly selected segments. Their endpoints in `anchors` move with them, but are not
|
||||
/// selected anchor points: Delete cuts connections and their anchor squares stay hollow.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub segments: BTreeMap<NodeId, BTreeSet<SegmentRef>>,
|
||||
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
|
||||
/// it when every selected object is the key or lies inside it, as when one object is left
|
||||
/// selected or when the key is a group selected with only its own members.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<NodeId>,
|
||||
/// The object, group or layer targeted through its target circle in the Layers panel, which
|
||||
@@ -29,8 +38,9 @@ pub struct Selection {
|
||||
/// clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<NodeId>,
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
|
||||
/// selected on their own: any change to the object selection clears them.
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new
|
||||
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
|
||||
/// guides and objects can be selected together, in either order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
}
|
||||
@@ -52,6 +62,7 @@ impl Selection {
|
||||
pub fn clear(&mut self) {
|
||||
self.objects.clear();
|
||||
self.anchors.clear();
|
||||
self.segments.clear();
|
||||
self.key = None;
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
@@ -59,14 +70,13 @@ impl Selection {
|
||||
}
|
||||
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
for id in ids {
|
||||
self.add(id);
|
||||
}
|
||||
// What `add` per id does after a clear, without its linear duplicate check per id.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.objects.extend(ids.into_iter().filter(|id| seen.insert(*id)));
|
||||
}
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -74,9 +84,9 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
self.segments.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
self.key = None;
|
||||
}
|
||||
@@ -99,6 +109,15 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
} else {
|
||||
self.guides.push(i);
|
||||
}
|
||||
}
|
||||
pub fn toggle(&mut self, id: NodeId) {
|
||||
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
||||
}
|
||||
@@ -106,18 +125,77 @@ impl Selection {
|
||||
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
|
||||
self.anchors.get(&id)
|
||||
}
|
||||
/// The objects selected whole: those with no direct-selected anchors.
|
||||
pub fn whole(&self) -> Vec<NodeId> {
|
||||
self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect()
|
||||
}
|
||||
/// With direct-selected anchors, what a transform of them measures from: the box around those
|
||||
/// anchors and the objects selected whole (`visual`: with their strokes). `None` without any.
|
||||
pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option<vectorcraft_geom::Rect> {
|
||||
if self.anchors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc = doc.bounds_of(&self.whole(), visual);
|
||||
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)) {
|
||||
acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p)));
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
/// Which direction handles belong to selected segments; both for an anchor selection.
|
||||
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
|
||||
let Some(segments) = self.segments.get(&id) else { return (true, true) };
|
||||
let previous = ai.checked_sub(1).or_else(|| sp.closed.then_some(sp.segment_count()).and_then(|n| n.checked_sub(1)));
|
||||
(previous.is_some_and(|i| segments.contains(&(si, i))), segments.contains(&(si, ai)))
|
||||
}
|
||||
/// Drop ids that no longer exist or are no longer editable.
|
||||
pub fn prune(&mut self, doc: &Document) {
|
||||
self.objects.retain(|id| doc.node(*id).is_some());
|
||||
self.anchors.retain(|id, _| doc.node(*id).is_some());
|
||||
if self.key.is_some_and(|k| doc.node(k).is_none()) {
|
||||
// Every id this looks up, found in one walk of the tree.
|
||||
let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target);
|
||||
let found = doc.index_paths(ids);
|
||||
self.objects.retain(|id| found.contains_key(id));
|
||||
self.anchors.retain(|id, _| found.contains_key(id));
|
||||
self.segments.retain(|id, refs| {
|
||||
let Some(path) = found.get(id).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()) else { return false };
|
||||
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.
|
||||
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))
|
||||
});
|
||||
if !keep {
|
||||
self.key = None;
|
||||
}
|
||||
if self.target.is_some_and(|t| doc.node(t).is_none()) {
|
||||
if self.target.is_some_and(|t| !found.contains_key(&t)) {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
self.guides.retain(|i| *i < doc.guides.len());
|
||||
// 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)));
|
||||
}
|
||||
/// 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() {
|
||||
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.key = None;
|
||||
}
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
@@ -157,53 +235,4 @@ impl Selection {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_ops() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.add(NodeId(1));
|
||||
s.toggle(NodeId(2));
|
||||
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
|
||||
s.toggle(NodeId(1));
|
||||
assert_eq!(s.objects, vec![NodeId(2)]);
|
||||
s.clear();
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_are_selected_on_their_own() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
s.add(NodeId(1));
|
||||
assert!(s.guides.is_empty());
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(2), NodeId(3)]);
|
||||
s.target = Some(NodeId(1));
|
||||
assert_eq!(s.subjects(), &[NodeId(1)]);
|
||||
s.add(NodeId(4));
|
||||
assert_eq!(s.target, None);
|
||||
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
|
||||
s.target = Some(NodeId(1));
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.target, None);
|
||||
s.target = Some(NodeId(1));
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::Node;
|
||||
|
||||
#[test]
|
||||
fn basic_ops() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.add(NodeId(1));
|
||||
s.toggle(NodeId(2));
|
||||
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
|
||||
s.toggle(NodeId(1));
|
||||
assert_eq!(s.objects, vec![NodeId(2)]);
|
||||
s.clear();
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_are_selected_on_their_own() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
// Shift-adding an object keeps them; a new selection doesn't.
|
||||
s.add(NodeId(1));
|
||||
assert_eq!(s.guides, vec![2, 0]);
|
||||
s.set([NodeId(1)]);
|
||||
assert!(s.guides.is_empty());
|
||||
// Toggled, a guide joins (or leaves) the selected objects.
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
|
||||
assert!(d.guide_shown(&g) && d.guide_editable(&g));
|
||||
d.node_mut(layer).unwrap().locked = true;
|
||||
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
|
||||
d.node_mut(layer).unwrap().visible = false;
|
||||
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
|
||||
// On no layer, or a layer that's gone: always there.
|
||||
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
|
||||
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
|
||||
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
|
||||
d.guides.push(g);
|
||||
assert_eq!(d.guides_on(layer).count(), 1);
|
||||
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
|
||||
d.node_mut(layer).unwrap().visible = true;
|
||||
d.node_mut(layer).unwrap().locked = false;
|
||||
d.guides.push(crate::Guide::new(false, 5.0));
|
||||
d.guides.push(gone);
|
||||
assert_eq!(d.adopt_guides(), 2);
|
||||
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
|
||||
assert_eq!(d.adopt_guides(), 0, "once");
|
||||
// A guide saved before guides had layers reads as one on no layer.
|
||||
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
|
||||
assert_eq!(old.layer, None);
|
||||
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(2), NodeId(3)]);
|
||||
s.target = Some(NodeId(1));
|
||||
assert_eq!(s.subjects(), &[NodeId(1)]);
|
||||
s.add(NodeId(4));
|
||||
assert_eq!(s.target, None);
|
||||
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
|
||||
s.target = Some(NodeId(1));
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.target, None);
|
||||
s.target = Some(NodeId(1));
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(1), NodeId(2)]);
|
||||
s.key = Some(NodeId(2));
|
||||
s.remove(NodeId(1));
|
||||
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.key, None, "the key object itself left");
|
||||
// Pruning drops objects the document no longer has and clears the key once no other
|
||||
// selected object lies outside it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for _ in 0..4 {
|
||||
let id = d.alloc_id();
|
||||
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
|
||||
}
|
||||
s.set(ids.iter().copied());
|
||||
s.key = Some(ids[3]);
|
||||
d.remove(ids[0]).unwrap();
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
|
||||
s.toggle(ids[1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
|
||||
s.toggle(ids[2]);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
|
||||
// A group key selected with only its own member is cleared too; another selected object
|
||||
// outside the group keeps it.
|
||||
let member = d.alloc_id();
|
||||
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
|
||||
s.set([member, ids[3]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, None, "a group and its own member");
|
||||
s.set([member, ids[3], ids[2]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
|
||||
}
|
||||
|
||||
fn below(r: &mut u64, n: u64) -> u64 {
|
||||
*r ^= *r << 13;
|
||||
*r ^= *r >> 7;
|
||||
*r ^= *r << 17;
|
||||
*r % n.max(1)
|
||||
}
|
||||
|
||||
fn random_group(d: &mut Document, r: &mut u64, depth: u64, ids: &mut Vec<NodeId>) -> Arc<Node> {
|
||||
let id = d.alloc_id();
|
||||
ids.push(id);
|
||||
let count = if depth == 0 { 0 } else { 1 + below(r, 4) };
|
||||
let children = (0..count)
|
||||
.map(|_| {
|
||||
let depth = below(r, depth);
|
||||
random_group(d, r, depth, ids)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(id, children);
|
||||
group.locked = below(r, 6) == 0;
|
||||
group.visible = below(r, 6) != 0;
|
||||
Arc::new(group)
|
||||
}
|
||||
|
||||
fn random_document(r: &mut u64) -> (Document, Vec<NodeId>) {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut ids = vec![];
|
||||
for _ in 0..3 {
|
||||
let mut layer = Node::layer(d.alloc_id(), "L", crate::LayerColor::Preset(0));
|
||||
let children = (0..1 + below(r, 4)).map(|_| random_group(&mut d, r, 3, &mut ids)).collect();
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
layer.locked = below(r, 6) == 0;
|
||||
layer.visible = below(r, 6) != 0;
|
||||
d.layers.push(Arc::new(layer));
|
||||
}
|
||||
(d, ids)
|
||||
}
|
||||
|
||||
fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
let missing = (below(r, 2) == 0).then_some(NodeId(u64::MAX));
|
||||
s.set(ids.iter().copied().filter(|_| below(r, 2) == 0).chain(missing));
|
||||
let k = below(r, s.objects.len() as u64) as usize;
|
||||
s.objects.rotate_left(k);
|
||||
for &o in &s.objects {
|
||||
match below(r, 3) {
|
||||
0 => {
|
||||
s.anchors.insert(o, BTreeSet::from([(0, 0)]));
|
||||
}
|
||||
1 => {
|
||||
s.anchors.insert(o, BTreeSet::from([(0, 1)]));
|
||||
s.segments.insert(o, BTreeSet::from([(0, 0)]));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied();
|
||||
s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0);
|
||||
s.slices = ids.iter().copied().take(below(r, 3) as usize).collect();
|
||||
s.guides = vec![1, 0];
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_uneditable_matches_removing_one_at_a_time() {
|
||||
let (mut unchanged, mut partial) = (0, 0);
|
||||
for seed in 1..400u64 {
|
||||
let mut r = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
|
||||
let (d, mut ids) = random_document(&mut r);
|
||||
if seed % 4 == 0 {
|
||||
ids.retain(|id| d.is_editable(*id));
|
||||
}
|
||||
let s = random_selection(&mut r, &ids);
|
||||
let mut expected = s.clone();
|
||||
for o in s.objects.iter().copied().filter(|o| !d.is_editable(*o)) {
|
||||
expected.remove(o);
|
||||
}
|
||||
let mut actual = s.clone();
|
||||
actual.deselect_uneditable(&d);
|
||||
assert_eq!(actual, expected, "seed {seed}");
|
||||
unchanged += usize::from(actual == s);
|
||||
partial += usize::from(!actual.objects.is_empty() && actual.objects.len() < s.objects.len());
|
||||
}
|
||||
assert!(unchanged > 10 && partial > 100, "{unchanged} {partial}");
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! [`Shared`]: a value shared between copies of a node until one of them changes it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// A copy-on-write value: cloning shares it, and the first change through `DerefMut` makes the
|
||||
/// changed copy its own (`Arc::make_mut`). A node moved or recoloured keeps sharing what it didn't
|
||||
/// change with the version the undo history holds. Reads, writes, equality and the saved form are
|
||||
/// exactly those of the value itself.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Shared<T>(Arc<T>);
|
||||
|
||||
impl<T> Shared<T> {
|
||||
pub fn new(v: T) -> Self {
|
||||
Shared(Arc::new(v))
|
||||
}
|
||||
/// Whether `a` and `b` share one value (an unchanged copy).
|
||||
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
|
||||
Arc::ptr_eq(&a.0, &b.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Shared<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> std::ops::DerefMut for Shared<T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
Arc::make_mut(&mut self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Shared<T> {
|
||||
/// The value, taken out (copied only when another copy still shares it).
|
||||
pub fn into_inner(self) -> T {
|
||||
Arc::unwrap_or_clone(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// `for x in std::mem::take(&mut doc.swatches)` takes the value, as it would the value itself.
|
||||
impl<T: Clone + IntoIterator> IntoIterator for Shared<T> {
|
||||
type Item = T::Item;
|
||||
type IntoIter = T::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.into_inner().into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// `for x in &doc.swatches` iterates the shared value, as it would the value itself.
|
||||
impl<'a, T> IntoIterator for &'a Shared<T>
|
||||
where
|
||||
&'a T: IntoIterator,
|
||||
{
|
||||
type Item = <&'a T as IntoIterator>::Item;
|
||||
type IntoIter = <&'a T as IntoIterator>::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
(&*self.0).into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// `for x in &mut doc.swatches` makes the value this copy's own first, as any change does.
|
||||
impl<'a, T: Clone> IntoIterator for &'a mut Shared<T>
|
||||
where
|
||||
&'a mut T: IntoIterator,
|
||||
{
|
||||
type Item = <&'a mut T as IntoIterator>::Item;
|
||||
type IntoIter = <&'a mut T as IntoIterator>::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
Arc::make_mut(&mut self.0).into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for Shared<T> {
|
||||
fn from(v: T) -> Self {
|
||||
Shared::new(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for Shared<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.0, &other.0) || *self.0 == *other.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared value equals the plain value it holds (`node.appearance == style.appearance`).
|
||||
impl<T: PartialEq> PartialEq<T> for Shared<T> {
|
||||
fn eq(&self, other: &T) -> bool {
|
||||
*self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Debug> std::fmt::Debug for Shared<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> Serialize for Shared<T> {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
self.0.serialize(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Shared<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
T::deserialize(d).map(Shared::new)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::text::TextStyleDef;
|
||||
use crate::variables::{Variable, VariableKind};
|
||||
use crate::{Document, NodeId, SavedSelection};
|
||||
|
||||
/// A document with every shared resource set, changed only through `push`/`insert` (as code
|
||||
/// written for the plain values does).
|
||||
fn doc_with_resources() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let swatch = d.swatches[1].clone();
|
||||
d.swatches.push(swatch);
|
||||
d.swatch_groups.truncate(1);
|
||||
d.graphic_styles.truncate(2);
|
||||
d.char_styles.push(TextStyleDef { name: "Emphasis".into(), attrs: json!({"size": 14}).as_object().cloned().unwrap_or_default() });
|
||||
d.para_styles.push(TextStyleDef { name: "Body".into(), attrs: Default::default() });
|
||||
d.saved_selections.push(SavedSelection { name: "Saved".into(), objects: vec![NodeId(1)] });
|
||||
d.variables.variables.push(Variable { name: "Name".into(), kind: VariableKind::Text });
|
||||
d.variables.bindings.entry(NodeId(1)).or_default().insert("Name".into());
|
||||
d.unknown.insert("foreign".into(), json!({"keep": [1, 2]}));
|
||||
d
|
||||
}
|
||||
|
||||
/// Shared document resources save as the values themselves: the saved form is unchanged.
|
||||
#[test]
|
||||
fn shared_resources_save_and_load_as_their_values() {
|
||||
let d = doc_with_resources();
|
||||
let v = serde_json::to_value(&d).unwrap();
|
||||
assert_eq!(v["swatches"], serde_json::to_value(&*d.swatches).unwrap());
|
||||
assert_eq!(v["swatch_groups"], serde_json::to_value(&*d.swatch_groups).unwrap());
|
||||
assert_eq!(v["graphic_styles"], serde_json::to_value(&*d.graphic_styles).unwrap());
|
||||
assert_eq!(v["char_styles"], json!([{"name": "Emphasis", "attrs": {"size": 14}}]));
|
||||
assert_eq!(v["para_styles"], json!([{"name": "Body", "attrs": {}}]));
|
||||
assert_eq!(v["saved_selections"], json!([{"name": "Saved", "objects": [1]}]));
|
||||
assert_eq!(v["variables"], json!({"variables": [{"name": "Name", "kind": "text"}], "bindings": {"1": ["Name"]}}));
|
||||
assert_eq!(v["unknown"], json!({"foreign": {"keep": [1, 2]}}));
|
||||
let text = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back, d);
|
||||
assert_eq!(serde_json::to_string(&back).unwrap(), text, "saving again gives the same bytes");
|
||||
// Empty ones are left out, as before.
|
||||
let v = serde_json::to_value(Document::new(10.0, 10.0)).unwrap();
|
||||
for k in ["char_styles", "para_styles", "saved_selections", "variables", "unknown"] {
|
||||
assert!(v.get(k).is_none(), "{k} is left out when empty");
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of a document shares its resources until one copy changes one of them.
|
||||
#[test]
|
||||
fn copies_share_resources_until_changed() {
|
||||
let a = doc_with_resources();
|
||||
let mut b = a.clone();
|
||||
assert!(super::Shared::ptr_eq(&a.swatches, &b.swatches) && super::Shared::ptr_eq(&a.unknown, &b.unknown));
|
||||
b.swatches.pop();
|
||||
assert!(!super::Shared::ptr_eq(&a.swatches, &b.swatches));
|
||||
assert_eq!(a.swatches.len(), b.swatches.len() + 1, "the other copy keeps its own");
|
||||
assert!(super::Shared::ptr_eq(&a.graphic_styles, &b.graphic_styles), "the rest stays shared");
|
||||
}
|
||||
}
|
||||
@@ -177,13 +177,13 @@ impl Document {
|
||||
}
|
||||
/// Is `id` a user slice or an object with slice options?
|
||||
pub fn is_slice(&self, id: NodeId) -> bool {
|
||||
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some())
|
||||
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.extras().slice.is_some())
|
||||
}
|
||||
/// The options of slice `id` (user or object).
|
||||
pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> {
|
||||
match self.slice(id) {
|
||||
Some(s) => Some(&s.options),
|
||||
None => self.node(id)?.slice.as_deref(),
|
||||
None => self.node(id)?.extras().slice.as_deref(),
|
||||
}
|
||||
}
|
||||
/// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's
|
||||
@@ -191,14 +191,14 @@ impl Document {
|
||||
pub fn slice_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
match self.slice(id) {
|
||||
Some(s) => Some(s.rect),
|
||||
None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(),
|
||||
None => self.node(id).filter(|n| n.extras().slice.is_some())?.visual_bounds(),
|
||||
}
|
||||
}
|
||||
/// The visible objects with slice options and their visual bounds, in paint order.
|
||||
pub fn object_slices(&self) -> Vec<(NodeId, Rect)> {
|
||||
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(NodeId, Rect)>) {
|
||||
for n in nodes.iter().filter(|n| n.visible) {
|
||||
if n.slice.is_some()
|
||||
if n.extras().slice.is_some()
|
||||
&& let Some(b) = n.visual_bounds()
|
||||
{
|
||||
out.push((n.id, b));
|
||||
|
||||
@@ -163,7 +163,12 @@ impl Document {
|
||||
}
|
||||
})
|
||||
};
|
||||
self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n));
|
||||
self.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| &p.art))
|
||||
.for_each(|n| tree(n));
|
||||
self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
|
||||
//! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes
|
||||
//! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and
|
||||
//! `move_node` leave one node at a time.
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so
|
||||
/// first-in-tree-order matters.
|
||||
fn tree(seed: u64) -> (Document, Vec<NodeId>) {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let mut r = seed | 1;
|
||||
let mut next = |n: u64| {
|
||||
r ^= r << 13;
|
||||
r ^= r >> 7;
|
||||
r ^= r << 17;
|
||||
r % n
|
||||
};
|
||||
let mut ids = vec![];
|
||||
let leaf = |d: &mut Document, ids: &mut Vec<NodeId>| {
|
||||
let id = if ids.len() % 7 == 6 { ids[ids.len() / 2] } else { d.alloc_id() };
|
||||
ids.push(id);
|
||||
Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()))
|
||||
};
|
||||
for l in 0..3 {
|
||||
let lid = d.alloc_id();
|
||||
let mut layer = Node::layer(lid, &format!("L{l}"), LayerColor::Preset(0));
|
||||
ids.push(lid);
|
||||
for _ in 0..(5 + next(10)) {
|
||||
let child = if next(3) == 0 {
|
||||
let mut inner: Vec<Arc<Node>> = (0..(1 + next(4))).map(|_| leaf(&mut d, &mut ids)).collect();
|
||||
for _ in 0..next(3) {
|
||||
let gid = d.alloc_id();
|
||||
ids.push(gid);
|
||||
inner = vec![Arc::new(Node::group(gid, inner))];
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
ids.push(gid);
|
||||
Arc::new(Node::group(gid, inner))
|
||||
} else {
|
||||
leaf(&mut d, &mut ids)
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.push(child);
|
||||
}
|
||||
}
|
||||
d.layers.push(Arc::new(layer));
|
||||
}
|
||||
ids.push(NodeId(999_999)); // not in the tree
|
||||
(d, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_lookups_match_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (d, ids) = tree(seed);
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
for id in &ids {
|
||||
assert_eq!(paths.get(id).cloned(), d.index_path(*id), "seed {seed} id {id:?}");
|
||||
let p = paths.get(id);
|
||||
assert_eq!(p.and_then(|p| d.node_at(p)).map(|n| n as *const Node), d.node(*id).map(|n| n as *const Node), "seed {seed} node {id:?}");
|
||||
assert_eq!(p.and_then(|p| d.ancestry_at(p)), d.ancestry(*id), "seed {seed} ancestry {id:?}");
|
||||
}
|
||||
// A subset, and nothing.
|
||||
let some: Vec<NodeId> = ids.iter().copied().step_by(3).collect();
|
||||
let sub = d.index_paths(some.iter().copied());
|
||||
assert!(some.iter().all(|id| sub.get(id).cloned() == d.index_path(*id)));
|
||||
assert!(d.index_paths(std::iter::empty()).is_empty());
|
||||
// Mutable access reaches the same node as node_mut, copy-on-write.
|
||||
let mut a = d.clone();
|
||||
let mut b = d.clone();
|
||||
for id in ids.iter().step_by(5) {
|
||||
if let Some(n) = a.node_mut(*id) {
|
||||
n.opacity = 0.25;
|
||||
}
|
||||
if let Some(n) = d.index_path(*id).and_then(|p| b.node_mut_at(&p)) {
|
||||
n.opacity = 0.25;
|
||||
}
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&a).ok(), serde_json::to_string(&b).ok(), "seed {seed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a walk of the tree gives for every id, checked against the cached lookups.
|
||||
fn agree(d: &Document, ids: &[NodeId], what: &str) {
|
||||
agree_except(d, ids, None, what);
|
||||
}
|
||||
|
||||
/// [`agree`], except for `dup`, an id given a second node after the cache was built: lookups of it
|
||||
/// may return either of its nodes (see `id_index`), but always one with that id.
|
||||
fn agree_except(d: &Document, ids: &[NodeId], dup: Option<NodeId>, what: &str) {
|
||||
if let Some(dup) = dup {
|
||||
assert_eq!(d.node(dup).map(|n| n.id), Some(dup), "{what}: node {dup:?} has its id");
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.iter().copied().filter(|id| Some(*id) != dup).collect();
|
||||
let ids = &ids[..];
|
||||
for id in ids {
|
||||
let walked = d.walk_index_path(*id);
|
||||
assert_eq!(d.index_path(*id), walked, "{what}: index_path {id:?}");
|
||||
let by_walk = walked.as_deref().and_then(|p| d.node_at(p)).map(|n| n as *const Node);
|
||||
assert_eq!(d.node(*id).map(|n| n as *const Node), by_walk, "{what}: node {id:?}");
|
||||
}
|
||||
let batched = d.index_paths(ids.iter().copied());
|
||||
for id in ids {
|
||||
assert_eq!(batched.get(id).cloned(), d.walk_index_path(*id), "{what}: index_paths {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The id cache never answers differently from a walk, however the tree is changed behind its
|
||||
/// back (through the public fields), including in a clone that shares the cache.
|
||||
#[test]
|
||||
fn cached_lookups_follow_any_edit_of_the_tree() {
|
||||
for seed in 1..30 {
|
||||
let (mut d, ids) = tree(seed);
|
||||
agree(&d, &ids, "fresh");
|
||||
let snapshot = d.clone(); // shares the cache
|
||||
// Insert at the front of the first layer: every path in it shifts.
|
||||
let extra = d.alloc_id();
|
||||
let leaf = Arc::new(Node::path(extra, shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
ch.insert(0, leaf.clone());
|
||||
}
|
||||
let mut all = ids.clone();
|
||||
all.push(extra);
|
||||
agree(&d, &all, "inserted at the front");
|
||||
// Remove a child, swap two, and move one into another layer.
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[1]).children_mut()
|
||||
&& ch.len() > 3
|
||||
{
|
||||
ch.remove(1);
|
||||
ch.swap(0, 2);
|
||||
}
|
||||
let moved = Arc::make_mut(&mut d.layers[2]).children_mut().and_then(|ch| (!ch.is_empty()).then(|| ch.remove(0)));
|
||||
if let Some(m) = moved
|
||||
&& let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut()
|
||||
{
|
||||
ch.push(m);
|
||||
}
|
||||
agree(&d, &all, "removed, swapped and moved");
|
||||
// A new duplicate of an existing id, earlier in tree order than the original.
|
||||
let dup = ids[ids.len() / 3];
|
||||
let copy = Arc::new(Node::path(dup, shapes::rectangle(Rect::new(0.0, 0.0, 2.0, 2.0)), Appearance::default_art()));
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
ch.insert(0, copy);
|
||||
}
|
||||
agree_except(&d, &all, Some(dup), "a duplicate inserted earlier");
|
||||
// Delete a whole layer, then look everything up again, missing ids included.
|
||||
d.layers.remove(1);
|
||||
agree_except(&d, &all, Some(dup), "a layer deleted");
|
||||
// A rebuilt cache (as after loading a file) knows the duplicate and walks for it.
|
||||
let mut fresh = d.clone();
|
||||
fresh.layers = d.layers.clone();
|
||||
fresh.index = Default::default();
|
||||
agree(&fresh, &all, "a damaged tree, cached afresh");
|
||||
// The clone made before all this still answers for its own tree.
|
||||
agree(&snapshot, &ids, "the earlier clone");
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes of `d` picked and ordered by `seed`. None lies inside another, and each of them, and each
|
||||
/// node it is in, appears in the tree once.
|
||||
fn picks(d: &Document, seed: u64) -> Vec<NodeId> {
|
||||
let mut count = std::collections::HashMap::<NodeId, usize>::new();
|
||||
d.walk(|n| *count.entry(n.id).or_default() += 1);
|
||||
let once = |id: &NodeId| count.get(id) == Some(&1);
|
||||
let mut found: Vec<(Vec<usize>, NodeId)> = count
|
||||
.keys()
|
||||
.filter(|id| !(id.0 + seed).is_multiple_of(3))
|
||||
.filter_map(|id| d.index_path(*id).map(|p| (p, *id)))
|
||||
.filter(|(p, _)| d.ancestry_at(p).is_some_and(|a| a.iter().all(once)))
|
||||
.collect();
|
||||
found.sort();
|
||||
let mut kept: Vec<(Vec<usize>, NodeId)> = vec![];
|
||||
for (p, id) in found {
|
||||
if !kept.iter().any(|(q, _)| p.starts_with(q)) {
|
||||
kept.push((p, id));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<NodeId> = kept.into_iter().map(|(_, id)| id).collect();
|
||||
out.sort_by_key(|id| id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ seed);
|
||||
out
|
||||
}
|
||||
|
||||
/// `a` and `b` hold the same tree, and their lookups agree with each other and with a walk.
|
||||
fn same(a: &Document, b: &Document, ids: &[NodeId], what: &str) {
|
||||
assert_eq!(serde_json::to_string(a).ok(), serde_json::to_string(b).ok(), "{what}");
|
||||
for id in ids {
|
||||
assert_eq!(a.index_path(*id), b.index_path(*id), "{what}: {id:?}");
|
||||
}
|
||||
agree(b, ids, what);
|
||||
}
|
||||
|
||||
/// A 1 pt square with a new id.
|
||||
fn square(d: &mut Document) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_many_matches_removing_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (d, ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let one: Vec<Arc<Node>> = picked.iter().map(|id| a.remove(*id).unwrap()).collect();
|
||||
let many = b.remove_many(&picked).unwrap();
|
||||
assert_eq!(many.len(), one.len(), "seed {seed}");
|
||||
assert!(one.iter().zip(&many).all(|(x, y)| Arc::ptr_eq(x, y)), "seed {seed}: the same nodes, in the order of the list");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_beside_matches_inserting_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
for replace in [false, true] {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
// None to three new nodes for each anchor.
|
||||
let items: Vec<(NodeId, Vec<Node>)> =
|
||||
picked.iter().enumerate().map(|(k, id)| (*id, (0..k % 4).map(|_| square(&mut d)).collect())).collect();
|
||||
ids.extend(items.iter().flat_map(|(_, new)| new.iter().map(|n| n.id)));
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
for (id, new) in items.clone() {
|
||||
let (parent, at, _) = a.position(id).unwrap();
|
||||
let at = if replace {
|
||||
a.remove(id).unwrap();
|
||||
at
|
||||
} else {
|
||||
at + 1
|
||||
};
|
||||
for (k, n) in new.into_iter().enumerate() {
|
||||
a.insert(parent, at + k, n).unwrap();
|
||||
}
|
||||
}
|
||||
b.insert_beside(items, replace).unwrap();
|
||||
same(&a, &b, &ids, &format!("seed {seed}, replace {replace}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_matches_inserting_a_group_and_moving_the_nodes_into_it_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let Some(&last) = picked.last() else { continue };
|
||||
let group = d.alloc_id();
|
||||
ids.push(group);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let (parent, at, _) = a.position(last).unwrap();
|
||||
a.insert(parent, at + 1, Node::group(group, vec![])).unwrap();
|
||||
for id in &picked {
|
||||
a.move_node(*id, Some(group), usize::MAX).unwrap();
|
||||
}
|
||||
assert_eq!(b.wrap(&picked, |nodes| Node::group(group, nodes)), Ok(group), "seed {seed}");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// An id not in the tree, listed twice, or inside another listed node fails with `NoNode`, and the
|
||||
/// document stays as it was.
|
||||
#[test]
|
||||
fn edits_of_many_refuse_missing_repeated_and_nested_ids() {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
let (x, y, z) = (square(&mut d), square(&mut d), square(&mut d));
|
||||
let (inner, other) = (y.id, z.id);
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::group(group, vec![Arc::new(x), Arc::new(y)])).unwrap();
|
||||
d.insert(Some(layer), 1, z).unwrap();
|
||||
let saved = serde_json::to_string(&d).ok();
|
||||
let missing = NodeId(999_999);
|
||||
for (list, bad) in [(vec![other, missing], missing), (vec![other, other], other), (vec![group, inner], inner), (vec![inner, group], inner)] {
|
||||
let what = format!("{list:?}");
|
||||
let mut b = d.clone();
|
||||
assert_eq!(b.remove_many(&list).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
assert_eq!(b.wrap(&list, |nodes| Node::group(NodeId(1_000_000), nodes)).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
for replace in [false, true] {
|
||||
let items = list.iter().map(|id| (*id, vec![])).collect();
|
||||
assert_eq!(b.insert_beside(items, replace).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&b).ok(), saved, "{what}: the document stays as it was");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fn an_object_slice_follows_its_object_and_numbers_run_by_rows() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0));
|
||||
assert!(d.slice_layout().is_empty(), "no slices, no auto slices");
|
||||
d.node_mut(a).unwrap().slice = Some(Box::default());
|
||||
d.node_mut(a).unwrap().extras_mut().slice = Some(Box::default());
|
||||
let lay = d.slice_layout();
|
||||
let own = lay.iter().find(|s| s.id == Some(a)).unwrap();
|
||||
assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0)));
|
||||
@@ -69,14 +69,15 @@ fn clip_to_artboard_clips_slices_and_off_covers_the_art() {
|
||||
fn slices_survive_a_native_round_trip_and_old_files_load() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0));
|
||||
d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
|
||||
d.node_mut(a).unwrap().extras_mut().slice =
|
||||
Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
|
||||
let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0));
|
||||
d.slice_mut(s).unwrap().options.url = "https://example.com".into();
|
||||
d.slices_clip_to_artboard = false;
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.slices, d.slices);
|
||||
assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice);
|
||||
assert_eq!(back.node(a).unwrap().extras().slice, d.node(a).unwrap().extras().slice);
|
||||
assert!(!back.slices_clip_to_artboard);
|
||||
// A document without slices writes none of the new keys (older readers see what they knew).
|
||||
let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap();
|
||||
|
||||
@@ -95,6 +95,52 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||
/// its proportional heights, `vpal`.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: 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,
|
||||
}
|
||||
|
||||
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
|
||||
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps
|
||||
/// the digits that were typed; only the glyphs change (like All Caps).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Digits {
|
||||
/// Each digit as typed.
|
||||
#[default]
|
||||
Default,
|
||||
/// Western digits 0–9 (the digits Arabic typography calls Arabic).
|
||||
Arabic,
|
||||
/// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic.
|
||||
Hindi,
|
||||
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
|
||||
Farsi,
|
||||
}
|
||||
|
||||
impl Digits {
|
||||
/// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c`
|
||||
/// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set.
|
||||
pub fn map(self, c: char) -> char {
|
||||
let value = match c {
|
||||
'0'..='9' => c as u32 - '0' as u32,
|
||||
'\u{660}'..='\u{669}' => c as u32 - 0x660,
|
||||
'\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0,
|
||||
_ => return c,
|
||||
};
|
||||
let base = match self {
|
||||
Digits::Default => return c,
|
||||
Digits::Arabic => '0' as u32,
|
||||
Digits::Hindi => 0x660,
|
||||
Digits::Farsi => 0x6F0,
|
||||
};
|
||||
char::from_u32(base + value).unwrap_or(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a character smaller than the largest on its line lines up with it: on the Roman
|
||||
@@ -205,7 +251,9 @@ impl Default for CharStyle {
|
||||
stroke_dash: None,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
digits: Digits::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -954,7 +1002,49 @@ pub struct TextObject {
|
||||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
|
||||
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextStyleScale {
|
||||
pub points: f64,
|
||||
pub horizontal: f64,
|
||||
}
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
/// be edited by compensating the stored attributes.
|
||||
pub fn style_scale(&self) -> Option<TextStyleScale> {
|
||||
let [a, b, c, d, e, f] = self.xf.as_coeffs();
|
||||
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
|
||||
let horizontal = sx / sy;
|
||||
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Avoid a pure rotation turning otherwise identical fields into mixed values.
|
||||
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
|
||||
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
|
||||
}
|
||||
|
||||
/// Character attributes as presented by controls (stored runs stay local for layout
|
||||
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
|
||||
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
|
||||
let scale = self.style_scale()?;
|
||||
let mut style = style.clone();
|
||||
style.size *= scale.points;
|
||||
style.leading = style.leading.map(|v| v * scale.points);
|
||||
style.baseline_shift *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(style)
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
@@ -1190,6 +1280,42 @@ impl TextObject {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
|
||||
/// are saved only when set.
|
||||
#[test]
|
||||
fn digits_map_between_the_numeral_systems() {
|
||||
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
|
||||
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
|
||||
assert_eq!(map(Digits::Default, s), s);
|
||||
assert_eq!(map(Digits::Arabic, s), "0123456789");
|
||||
assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩");
|
||||
assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹");
|
||||
}
|
||||
// Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets.
|
||||
assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔");
|
||||
let st = CharStyle::default();
|
||||
assert!(serde_json::to_value(&st).unwrap().get("digits").is_none());
|
||||
let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() };
|
||||
assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi");
|
||||
// Styles written before the attribute existed (no `digits` key) read as Default.
|
||||
let old = serde_json::to_value(&st).unwrap();
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().digits, Digits::Default);
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
|
||||
t.xf = Affine::new(matrix);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none());
|
||||
}
|
||||
t.xf = Affine::rotate(0.83);
|
||||
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
|
||||
t.xf = Affine::scale(1e308);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_text_basics() {
|
||||
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
|
||||
|
||||
@@ -67,7 +67,7 @@ impl TraceView {
|
||||
impl Node {
|
||||
/// The View of an Image Trace object (the tracing result for anything else, or an unknown id).
|
||||
pub fn trace_view(&self) -> TraceView {
|
||||
self.trace.as_deref().map(TraceView::of).unwrap_or_default()
|
||||
self.extras().trace.as_deref().map(TraceView::of).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ mod tests {
|
||||
}
|
||||
let mut g = Node::group(crate::NodeId(1), vec![]);
|
||||
assert_eq!(g.trace_view(), TraceView::Result);
|
||||
g.trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
|
||||
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
|
||||
assert_eq!(g.trace_view(), TraceView::Outlines);
|
||||
g.trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
|
||||
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
|
||||
assert_eq!(g.trace_view(), TraceView::Result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Variables (data merge): named values bound to objects, with datasets that apply
|
||||
//! them all at once. One template document produces many variants (badges, price
|
||||
//! lists, localized copies) by selecting a dataset instead of editing each object.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Document, NodeId, NodeKind};
|
||||
|
||||
/// What a variable drives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VariableKind {
|
||||
/// A type object's characters.
|
||||
#[default]
|
||||
Text,
|
||||
/// Whether bound objects show.
|
||||
Visibility,
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
"visibility" | "visible" => Some(Self::Visibility),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Visibility => "visibility",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One named variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variable {
|
||||
pub name: String,
|
||||
pub kind: VariableKind,
|
||||
}
|
||||
|
||||
/// One value a dataset gives a variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DataValue {
|
||||
Text(String),
|
||||
Visible(bool),
|
||||
}
|
||||
|
||||
impl DataValue {
|
||||
/// Whether this value fits the variable's kind (a dataset may carry values for
|
||||
/// variables of either kind; each binding takes what fits it).
|
||||
pub fn fits(&self, kind: VariableKind) -> bool {
|
||||
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
||||
}
|
||||
}
|
||||
|
||||
/// One named row of values, applied with `dataset.select`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DataSet {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub values: BTreeMap<String, DataValue>,
|
||||
}
|
||||
|
||||
impl DataSet {
|
||||
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
|
||||
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
|
||||
/// keeps it, and applying it again would skip it too.
|
||||
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
|
||||
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable state of a document: definitions, datasets, bindings
|
||||
/// (object id → the variables bound to it) and which dataset is active.
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
||||
/// defined, which is what makes two text variables on one object deterministic: the last one
|
||||
/// wins, and `applied` counts both.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub variables: Vec<Variable>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub datasets: Vec<DataSet>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// The variable `name`, if the document defines it.
|
||||
pub fn variable(&self, name: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.name == name)
|
||||
}
|
||||
|
||||
/// The dataset `name`, if the document has it.
|
||||
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
|
||||
self.datasets.iter().find(|d| d.name == name)
|
||||
}
|
||||
|
||||
/// Bind `id` to the variable `name`. True when it was not bound to it already.
|
||||
pub fn bind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
self.bindings.entry(id).or_default().insert(name.to_string())
|
||||
}
|
||||
|
||||
/// Drop `id`'s binding to `name` (and the binding itself when that was its last variable).
|
||||
/// True when there was one.
|
||||
pub fn unbind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
let Some(bound) = self.bindings.get_mut(&id) else { return false };
|
||||
let dropped = bound.remove(name);
|
||||
if bound.is_empty() {
|
||||
self.bindings.remove(&id);
|
||||
}
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The objects bound to the variable `name`, in document order.
|
||||
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
|
||||
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Drop the variables `names`, the dataset values that name them and the bindings to
|
||||
/// them. Datasets named here are left alone: their names are their own namespace (the
|
||||
/// active one is dropped by whoever deletes the dataset).
|
||||
pub fn prune(&mut self, names: &[String]) -> usize {
|
||||
// A set, so a long list of names costs a lookup per value rather than a scan.
|
||||
let names: BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
||||
let before = self.variables.len();
|
||||
self.variables.retain(|v| !names.contains(v.name.as_str()));
|
||||
for dataset in &mut self.datasets {
|
||||
dataset.values.retain(|k, _| !names.contains(k.as_str()));
|
||||
}
|
||||
for bound in self.bindings.values_mut() {
|
||||
bound.retain(|v| !names.contains(v.as_str()));
|
||||
}
|
||||
self.bindings.retain(|_, v| !v.is_empty());
|
||||
before - self.variables.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Forget the bindings of objects that are no longer in the document (after every edit),
|
||||
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
|
||||
/// would count as a skip in every dataset application and grow the saved file forever.
|
||||
pub fn prune_variable_bindings(&mut self) {
|
||||
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
|
||||
return;
|
||||
}
|
||||
let mut bindings = std::mem::take(&mut self.variables.bindings);
|
||||
bindings.retain(|id, _| self.node(*id).is_some());
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
|
||||
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
||||
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
||||
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
||||
/// able to show it again, and a capture must record it hidden.
|
||||
pub fn variable_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
||||
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
||||
/// or locked layer (the document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
return None;
|
||||
}
|
||||
let node = self.node(id)?;
|
||||
match (kind, &node.kind) {
|
||||
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
||||
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
||||
(VariableKind::Text, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The values a dataset captured right now: what each bound object holds, in the order the
|
||||
/// variables are defined. `dataset.capture` writes these into a new row and
|
||||
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
||||
/// them to tell whether the art still matches what it says.
|
||||
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
||||
let mut out = BTreeMap::new();
|
||||
for v in &self.variables.variables {
|
||||
// An object driven by several variables of the same kind reads from the first one
|
||||
// bound, so a capture is one value per variable and applies back the same way.
|
||||
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
|
||||
if let Some(value) = value {
|
||||
out.insert(v.name.clone(), value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-three-d = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
|
||||
+33
-19
@@ -39,7 +39,9 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
has_adjustment(n)
|
||||
crate::has_revolve(n)
|
||||
|| crate::has_solid(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
@@ -92,6 +94,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
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.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
let symbol = match &n.kind {
|
||||
@@ -158,24 +164,25 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
|
||||
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
|
||||
let id = d.alloc_id();
|
||||
let kind = path_kind(d, path, rule);
|
||||
Node {
|
||||
id,
|
||||
name: None,
|
||||
appearance: Appearance { items: vec![item], ..Default::default() },
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
knockout_shape: false,
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
// The group keeps the URL (one link around the pieces).
|
||||
attrs: None,
|
||||
kind,
|
||||
..n.clone()
|
||||
}
|
||||
let mut out = n.clone();
|
||||
out.id = id;
|
||||
out.name = None;
|
||||
out.appearance = Appearance { items: vec![item], ..Default::default() }.into();
|
||||
out.opacity = 1.0;
|
||||
out.blend = Default::default();
|
||||
out.isolate = false;
|
||||
out.knockout = Default::default();
|
||||
out.knockout_shape = false;
|
||||
out.kind = kind;
|
||||
let mut extras = out.extras_mut();
|
||||
extras.mask = None;
|
||||
extras.trace = None;
|
||||
extras.wrap = None;
|
||||
extras.graph = None;
|
||||
// The group keeps the URL (one link around the pieces).
|
||||
extras.attrs = None;
|
||||
drop(extras);
|
||||
out
|
||||
}
|
||||
|
||||
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
|
||||
@@ -226,6 +233,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
m.appearance.effects.retain(|e| e.visible);
|
||||
let mut pieces = vec![];
|
||||
@@ -289,6 +300,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Distort & Transform effects.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::util::*;
|
||||
@@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
map_nonlinear(path, pieces, f)
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
for j in 0..=ridges {
|
||||
let t = j as f64 / (ridges + 1) as f64;
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
|
||||
+181
-3
@@ -12,8 +12,8 @@
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
//! renderer, the exporters and Outline Stroke.
|
||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
||||
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and
|
||||
//! default values. Missing parameters always fall back to those defaults.
|
||||
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
|
||||
//! through their outlines.
|
||||
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
|
||||
@@ -42,6 +42,8 @@ mod marks;
|
||||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
@@ -68,6 +70,11 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, 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,
|
||||
};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
@@ -122,14 +129,26 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] },
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"threeD.revolve" => always(&["offset"]),
|
||||
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
|
||||
"threeD.inflate" => always(&["depth"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
|
||||
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
|
||||
"brushStrokes.spatter" => always(&["sprayRadius"]),
|
||||
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
|
||||
"brushStrokes.sumiE" => always(&["strokeWidth"]),
|
||||
"pixelate.colorHalftone" => always(&["maxRadius"]),
|
||||
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
|
||||
"texture.craquelure" => always(&["crackSpacing"]),
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -142,8 +161,12 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
|
||||
const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const ARTISTIC: &[&str] = &["Effect", "Artistic"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
@@ -173,6 +196,34 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) };
|
||||
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) };
|
||||
let mut v = vec![
|
||||
g(
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
"Rectangle…",
|
||||
@@ -264,7 +315,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.innerGlow",
|
||||
"Inner Glow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
|
||||
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}",
|
||||
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
|
||||
),
|
||||
r(
|
||||
@@ -276,6 +327,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.filmGrain",
|
||||
"Film Grain…",
|
||||
ARTISTIC,
|
||||
"{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(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
@@ -290,6 +348,83 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.accentedEdges",
|
||||
"Accented Edges…",
|
||||
BRUSH_STROKES,
|
||||
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges",
|
||||
json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.angledStrokes",
|
||||
"Angled Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'",
|
||||
json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.crosshatch",
|
||||
"Crosshatch…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges",
|
||||
json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.darkStrokes",
|
||||
"Dark Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes",
|
||||
json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.inkOutlines",
|
||||
"Ink Outlines…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows",
|
||||
json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.spatter",
|
||||
"Spatter…",
|
||||
BRUSH_STROKES,
|
||||
"{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush",
|
||||
json!({"sprayRadius": 10.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.sprayedStrokes",
|
||||
"Sprayed Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours",
|
||||
json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.sumiE",
|
||||
"Sumi-e…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape",
|
||||
json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}),
|
||||
),
|
||||
r(
|
||||
"distort.diffuseGlow",
|
||||
"Diffuse Glow…",
|
||||
DISTORT,
|
||||
"{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain",
|
||||
json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}),
|
||||
),
|
||||
r(
|
||||
"distort.glass",
|
||||
"Glass…",
|
||||
DISTORT,
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
|
||||
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
|
||||
),
|
||||
r(
|
||||
"distort.oceanRipple",
|
||||
"Ocean Ripple…",
|
||||
DISTORT,
|
||||
"{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water",
|
||||
json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}),
|
||||
),
|
||||
r(
|
||||
"sharpen.unsharpMask",
|
||||
"Unsharp Mask…",
|
||||
@@ -332,6 +467,48 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
|
||||
json!({"cellSize": 5.0}),
|
||||
),
|
||||
r(
|
||||
"texture.craquelure",
|
||||
"Craquelure…",
|
||||
TEXTURE,
|
||||
"{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones",
|
||||
json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}),
|
||||
),
|
||||
r(
|
||||
"texture.grain",
|
||||
"Grain…",
|
||||
TEXTURE,
|
||||
"{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white",
|
||||
json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}),
|
||||
),
|
||||
r(
|
||||
"texture.mosaicTiles",
|
||||
"Mosaic Tiles…",
|
||||
TEXTURE,
|
||||
"{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them",
|
||||
json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}),
|
||||
),
|
||||
r(
|
||||
"texture.patchwork",
|
||||
"Patchwork…",
|
||||
TEXTURE,
|
||||
"{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left",
|
||||
json!({"squareSize": 4.0, "relief": 8.0}),
|
||||
),
|
||||
r(
|
||||
"texture.stainedGlass",
|
||||
"Stained Glass…",
|
||||
TEXTURE,
|
||||
"{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart",
|
||||
json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}),
|
||||
),
|
||||
r(
|
||||
"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",
|
||||
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
|
||||
),
|
||||
r(
|
||||
"video.deinterlace",
|
||||
"De-Interlace…",
|
||||
@@ -520,6 +697,7 @@ pub fn is_geometry(id: &str) -> bool {
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& !is_three_d(id)
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option<Node> {
|
||||
g.opacity = std::mem::replace(&mut object.opacity, 1.0);
|
||||
g.blend = std::mem::take(&mut object.blend);
|
||||
g.isolate = std::mem::take(&mut object.isolate);
|
||||
g.mask = object.mask.take();
|
||||
g.attrs = object.attrs.take();
|
||||
g.extras_mut().mask = object.extras_mut().mask.take();
|
||||
g.extras_mut().attrs = object.extras_mut().attrs.take();
|
||||
if let Some(ch) = g.children_mut() {
|
||||
*ch = vec![Arc::new(object), Arc::new(marks)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Effect › Artistic. Original filters built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
|
||||
//! These are visual approximations, not reproductions of a proprietary algorithm.
|
||||
|
||||
use super::PixelSpace;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
const SALT_FILM: u64 = 256;
|
||||
|
||||
/// 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.
|
||||
#[derive(Default)]
|
||||
struct CellNoise {
|
||||
last: Option<(i64, i64, [f64; 4])>,
|
||||
}
|
||||
|
||||
impl CellNoise {
|
||||
fn at(&mut self, salt: u64, q: Point) -> f64 {
|
||||
let (Some(i), Some(j)) = (cell(q.x), cell(q.y)) else { return 0.0 };
|
||||
let corners = match self.last {
|
||||
Some((x, y, values)) if x == i && y == j => values,
|
||||
_ => {
|
||||
let at = |di: i64, dj: i64| 2.0 * rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0) - 1.0;
|
||||
let values = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)];
|
||||
self.last = Some((i, j, values));
|
||||
values
|
||||
}
|
||||
};
|
||||
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
|
||||
let (fx, fy) = (smooth(q.x - i as f64), smooth(q.y - j as f64));
|
||||
let [a, b, c, d] = corners;
|
||||
let top = a + (b - a) * fx;
|
||||
let bottom = c + (d - c) * fx;
|
||||
// Divide by the root sum of squared interpolation weights so corners and cell
|
||||
// interiors have the same variance, rather than imprinting a contrast grid.
|
||||
let norm = ((fx * fx + (1.0 - fx).powi(2)) * (fy * fy + (1.0 - fy).powi(2))).sqrt();
|
||||
(top + (bottom - top) * fy) / norm
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FilmNoise {
|
||||
low: CellNoise,
|
||||
high: CellNoise,
|
||||
}
|
||||
|
||||
impl FilmNoise {
|
||||
fn at(&mut self, q: Point) -> f32 {
|
||||
// Fixed independent rotations (0.6 and -0.35 radians) and a second-octave offset
|
||||
// keep the two lattices from lining up with pixel rows or with each other.
|
||||
let low = Point::new(0.8253356149096783 * q.x - 0.5646424733950354 * q.y, 0.5646424733950354 * q.x + 0.8253356149096783 * q.y);
|
||||
let high = Point::new(
|
||||
2.0 * (0.9393727128473789 * q.x + 0.34289780745545134 * q.y) + 0.37,
|
||||
2.0 * (-0.34289780745545134 * q.x + 0.9393727128473789 * q.y) - 0.37,
|
||||
);
|
||||
// Normalising the blend can exceed the corner range, so bound the final field.
|
||||
(0.8 * (0.65 * self.low.at(SALT_FILM, low) + 0.35 * self.high.at(SALT_FILM + 1, high))).clamp(-1.0, 1.0) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded footprint sampling during minification, with attenuation for very large pixels.
|
||||
/// Up to four samples approximate area filtering, not exact integration of every grain.
|
||||
fn grain_at(place: &Place, field: &mut FilmNoise, x: usize, y: usize) -> f32 {
|
||||
if place.px <= 0.5 {
|
||||
return field.at(place.at(x, y));
|
||||
}
|
||||
let mut sample = |dx, dy| field.at(place.to_rel * Point::new(x as f64 + dx, y as f64 + dy));
|
||||
let diagonal = 0.5 * (sample(0.25, 0.25) + sample(0.75, 0.75));
|
||||
// Blend sampling modes rather than changing the texture's strength suddenly at a zoom.
|
||||
if place.px < 0.75 {
|
||||
let t = ((place.px - 0.5) / 0.25) as f32;
|
||||
return field.at(place.at(x, y)) * (1.0 - t) + diagonal * t;
|
||||
}
|
||||
if place.px <= 1.0 {
|
||||
return diagonal;
|
||||
}
|
||||
let other = 0.5 * (sample(0.75, 0.25) + sample(0.25, 0.75));
|
||||
let t = (0.5 * ((place.px - 1.0) / 0.5).min(1.0)) as f32;
|
||||
// The four samples already average about a two-point footprint. Only wider pixels need
|
||||
// extra attenuation; /px alone suppressed coarse grain twice (checked against dense sampling).
|
||||
(diagonal * (1.0 - t) + other * t) / (place.px / 2.0).max(1.0) as f32
|
||||
}
|
||||
|
||||
/// Film Grain: monochrome grain in shadows and midtones, with brighter highlights as
|
||||
/// `highlight` (0..20) widens their area. `intensity` (0..10) increases the highlight lift and
|
||||
/// quiets highlight grain, including when the Highlight Area is zero;
|
||||
/// `grain` (0..20) sets the noise strength. The smooth grain field lies relative to the object's
|
||||
/// bounds' centre, following translations and redraws; changing the bounds shifts the pattern.
|
||||
/// Its scale is in document units; minification uses bounded approximate footprint filtering.
|
||||
/// Coverage is preserved, including fully transparent pixels. Constant work and no allocations.
|
||||
pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, grain: f64, highlight: f64, intensity: f64) {
|
||||
if ![grain, highlight, intensity].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let grain = (grain / 20.0).clamp(0.0, 1.0) as f32;
|
||||
let highlight = (highlight / 20.0).clamp(0.0, 1.0) as f32;
|
||||
let intensity = (intensity / 10.0).clamp(0.0, 1.0) as f32;
|
||||
if grain == 0.0 && (intensity == 0.0 || highlight == 0.0) {
|
||||
return;
|
||||
}
|
||||
let mut field = FilmNoise::default();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let c = straight(*p);
|
||||
let brightness = luma(c);
|
||||
// Wider highlight areas include more midtones, without lifting black or growing a halo.
|
||||
let threshold = 0.85 - 0.6 * highlight;
|
||||
let t = ((brightness - threshold) / (1.0 - threshold)).clamp(0.0, 1.0);
|
||||
let light = t * t * (3.0 - 2.0 * t) * highlight * intensity * 0.65;
|
||||
// Keep the grain strength even across shadows and midtones, then quiet it smoothly
|
||||
// above mid-grey to a quarter-strength floor at full intensity, before highlight
|
||||
// headroom. This keeps a softer grain on cream. Black takes light speckles after
|
||||
// clipping; white stays clean.
|
||||
// This tonal curve is an interpretation of the public description, not a measured
|
||||
// proprietary transfer function. Shared offsets stay neutral until a channel clips.
|
||||
let upper = ((brightness - 0.5) * 2.0).clamp(0.0, 1.0);
|
||||
let upper = upper * upper * (3.0 - 2.0 * upper);
|
||||
let amplitude = (0.4 * (1.0 - upper * (0.5 + 0.5 * intensity))).max(0.1).min(1.0 - brightness);
|
||||
let strength = amplitude * grain * (1.0 - light);
|
||||
// With no highlight lift the starting premultiplied channels are whole bytes. A
|
||||
// bounded offset below half a byte cannot change rounding, so avoid hashing it.
|
||||
// Leave a small margin for straight/premultiplied floating-point roundoff.
|
||||
if light == 0.0 && strength * f32::from(p[3]) < 0.45 {
|
||||
return;
|
||||
}
|
||||
let noise = if strength > 0.0 { grain_at(&place, &mut field, x, y) * strength } else { 0.0 };
|
||||
let c = c.map(|v| v + (1.0 - v) * light + noise);
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines,
|
||||
//! Spatter, Sprayed Strokes, Sumi-e).
|
||||
//!
|
||||
//! The stroke filters repaint the object with brush strokes: each pixel takes the object's colour
|
||||
//! averaged along a segment through it ([`Smear`], a bounded number of samples whatever the
|
||||
//! length). Strokes lie in rows across their direction, each row starting at random and each
|
||||
//! stroke shifted along itself at random, so they begin and end like separate strokes; a streaked
|
||||
//! noise along them gives them their bristle texture. Accented Edges and Ink Outlines find edges
|
||||
//! with Glowing Edges' Sobel operator over the softened object; Spatter and Sprayed Strokes shift
|
||||
//! the content by noise as Ocean Ripple does. Every pattern lies in document space around the
|
||||
//! object's centre, as the Texture filters' do.
|
||||
|
||||
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::{Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, gaussian, pick, to16};
|
||||
|
||||
const SALT_ANGLED: u64 = 144;
|
||||
const SALT_CROSS: u64 = 152;
|
||||
const SALT_DARK: u64 = 160;
|
||||
const SALT_INK: u64 = 168;
|
||||
const SALT_SPATTER: u64 = 176;
|
||||
const SALT_SPRAYED: u64 = 184;
|
||||
const SALT_SUMI: u64 = 192;
|
||||
|
||||
/// Sprayed Strokes' stroke directions, in its Stroke Direction menu's order: (label, parameter
|
||||
/// value).
|
||||
pub const STROKE_DIRECTIONS: [(&str, &str); 4] =
|
||||
[("Right Diagonal", "rightDiagonal"), ("Horizontal", "horizontal"), ("Left Diagonal", "leftDiagonal"), ("Vertical", "vertical")];
|
||||
|
||||
/// The way Sprayed Strokes are painted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StrokeDirection {
|
||||
RightDiagonal,
|
||||
Horizontal,
|
||||
LeftDiagonal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl StrokeDirection {
|
||||
const ALL: [StrokeDirection; 4] =
|
||||
[StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical];
|
||||
|
||||
/// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal
|
||||
/// otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&STROKE_DIRECTIONS, &Self::ALL, value, StrokeDirection::RightDiagonal)
|
||||
}
|
||||
|
||||
/// The unit vector along the strokes (document axes, y down).
|
||||
fn along(self) -> Vec2 {
|
||||
match self {
|
||||
StrokeDirection::RightDiagonal => RIGHT,
|
||||
StrokeDirection::Horizontal => Vec2::new(1.0, 0.0),
|
||||
StrokeDirection::LeftDiagonal => LEFT,
|
||||
StrokeDirection::Vertical => Vec2::new(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Along a right diagonal stroke, rising to the right (y down).
|
||||
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);
|
||||
|
||||
/// The most samples a stroke averages, whatever its length.
|
||||
const MAX_SAMPLES: usize = 16;
|
||||
|
||||
/// The share of its length by which a stroke is shifted along itself at random, at most.
|
||||
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;
|
||||
|
||||
/// Dark Strokes' short dark strokes and long light ones (points).
|
||||
pub(super) const DARK_LENGTH: f64 = 3.0;
|
||||
pub(super) const LIGHT_LENGTH: f64 = 9.0;
|
||||
|
||||
/// How soft (points, the σ) Ink Outlines' outlines are.
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
pub(super) fn stroke_reach(len: f64) -> f64 {
|
||||
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
|
||||
(0.5 + JITTER + 1.5 / (MAX_SAMPLES - 1) as f64) * len + MARGIN
|
||||
}
|
||||
|
||||
/// How far (document units) the tones reach that the stroke filters choose their strokes by.
|
||||
pub(super) const TONE_REACH: f64 = 3.0 * TONE_SOFTNESS + MARGIN;
|
||||
|
||||
/// How far Sumi-e reaches with strokes `width` across: the strokes, and the wet ink bleeding from
|
||||
/// 3 σ of 0.2 × the width.
|
||||
pub(super) fn sumi_reach(width: f64) -> f64 {
|
||||
stroke_reach(width).max(0.6 * width + MARGIN)
|
||||
}
|
||||
|
||||
/// The σ (points) Accented Edges softens the object by before finding its edges, for Edge Width
|
||||
/// `width` (1..14) and Smoothness `smoothness` (1..15).
|
||||
pub(super) fn accent_softness(width: f64, smoothness: f64) -> f64 {
|
||||
0.35 * width.clamp(1.0, 14.0) + 0.1 * smoothness.clamp(1.0, 15.0)
|
||||
}
|
||||
|
||||
/// How far Accented Edges reaches for Edge Width `width` and Smoothness `smoothness`.
|
||||
pub(super) fn accent_reach(width: f64, smoothness: f64) -> f64 {
|
||||
3.0 * accent_softness(width, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// How far Ink Outlines reaches with strokes `len` long.
|
||||
pub(super) fn ink_reach(len: f64) -> f64 {
|
||||
stroke_reach(len).max(3.0 * INK_SOFTNESS + MARGIN)
|
||||
}
|
||||
|
||||
/// How far (points) Crosshatch roughens the edges, with strokes `len` long.
|
||||
pub(super) fn rough(len: f64) -> f64 {
|
||||
0.1 * if len.is_finite() { len.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The longest shift (points) Spatter gives at Spray Radius `radius` (0..25).
|
||||
pub(super) fn spatter_shift(radius: f64) -> f64 {
|
||||
0.4 * radius.clamp(0.0, 25.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Sprayed Strokes' spray gives at Spray Radius `radius` (0..25).
|
||||
pub(super) fn spray_shift(radius: f64) -> f64 {
|
||||
0.3 * radius.clamp(0.0, 25.0)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Premultiplied colours (0..1).
|
||||
src: Vec<[f32; 4]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
place: &'a Place,
|
||||
len: f64,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl<'a> Smear<'a> {
|
||||
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 };
|
||||
let gap = if n > 1 { span / (n - 1) as f64 } else { 0.0 };
|
||||
let mut wide = to16(px);
|
||||
if gap > 1.0 {
|
||||
gaussian(&mut wide, w, 0.5 * gap);
|
||||
}
|
||||
let src = wide.iter().map(|p| p.map(|v| f32::from(v) / 65535.0)).collect();
|
||||
Self { src, w, h, place, len, n }
|
||||
}
|
||||
|
||||
/// The premultiplied colour (0..1) averaged along the stroke through `centre` (relative to the
|
||||
/// object's centre) along `dir`.
|
||||
fn at(&self, centre: Point, dir: Vec2) -> [f32; 4] {
|
||||
let half = dir * (0.5 * self.len);
|
||||
let (a, b) = (self.place.to_px * (centre - half), self.place.to_px * (centre + half));
|
||||
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
// Pixel centres at whole coordinates.
|
||||
let (mut x, mut y, step) = match self.n {
|
||||
0 | 1 => (0.5 * (a.x + b.x) - 0.5, 0.5 * (a.y + b.y) - 0.5, (0.0, 0.0)),
|
||||
n => (a.x - 0.5, a.y - 0.5, ((b.x - a.x) / (n - 1) as f64, (b.y - a.y) / (n - 1) as f64)),
|
||||
};
|
||||
let mut acc = [0.0f32; 4];
|
||||
for _ in 0..self.n {
|
||||
self.add(&mut acc, x, y);
|
||||
x += step.0;
|
||||
y += step.1;
|
||||
}
|
||||
acc.map(|v| v / self.n.max(1) as f32)
|
||||
}
|
||||
|
||||
/// Add the colour at (`x`, `y`) (pixel centres at whole coordinates), interpolated between the
|
||||
/// four pixels around it, to `acc`; transparent outside.
|
||||
fn add(&self, acc: &mut [f32; 4], x: f64, y: f64) {
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
if !(x0 >= -1.0 && y0 >= -1.0 && x0 < self.w as f64 && y0 < self.h as f64) {
|
||||
return;
|
||||
}
|
||||
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
|
||||
let (i, j) = (x0 as isize, y0 as isize);
|
||||
// Inside the raster: the four pixels as two pairs.
|
||||
if i >= 0 && j >= 0 && (i as usize) + 1 < self.w && (j as usize) + 1 < self.h {
|
||||
let at = j as usize * self.w + i as usize;
|
||||
if let (Some([p00, p10]), Some([p01, p11])) = (self.src.get(at..at + 2), self.src.get(at + self.w..at + self.w + 2)) {
|
||||
let (k00, k10, k01, k11) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
|
||||
for (c, a) in acc.iter_mut().enumerate() {
|
||||
*a += p00[c] * k00 + p10[c] * k10 + p01[c] * k01 + p11[c] * k11;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
|
||||
let Some(row) = usize::try_from(row).ok().filter(|r| *r < self.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 < self.w) else { continue };
|
||||
if let Some(p) = self.src.get(row * self.w + col) {
|
||||
let k = wx * wy;
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += v * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// `q`'s coordinates along `dir` (unit) and across it.
|
||||
fn frame(q: Point, dir: Vec2) -> (f64, f64) {
|
||||
(q.x * dir.x + q.y * dir.y, q.y * dir.x - q.x * dir.y)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if !(len > 0.0 && width > 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let (u, v) = frame(q, dir);
|
||||
let Some(row) = cell(v / width) else { return 0.0 };
|
||||
let Some(col) = cell(u / len + rand(salt, row, 0, 0)) else { return 0.0 };
|
||||
(2.0 * rand(salt, col, row, 1) - 1.0) * JITTER * len
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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) {
|
||||
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] {
|
||||
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 {
|
||||
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] {
|
||||
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> {
|
||||
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);
|
||||
// A step softened by σ pixels rises by 1 / (σ √(2π)) per pixel at most, and Sobel gives 8 ×
|
||||
// the slope; a pixel is the least it softens by.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Accented Edges: the object's edges accented in white chalk when `brightness` (0..50) is high,
|
||||
/// in black ink when it's low, about `width` (1..14) wide; `smoothness` (1..15) softens the object
|
||||
/// first and leaves out its fainter edges.
|
||||
pub(super) fn accented_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, brightness: f64, smoothness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let found = edges(px, w, h, &place, accent_softness(width, smoothness));
|
||||
let faint = (0.02 * smoothness.clamp(1.0, 15.0)) as f32;
|
||||
let level = (brightness / 50.0).clamp(0.0, 1.0) as f32;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let e = found.get(y * w + x).copied().unwrap_or(0.0);
|
||||
let e = (2.5 * (e - faint) / (1.0 - faint)).clamp(0.0, 1.0);
|
||||
let c = straight(*p);
|
||||
// Chalk or ink of the brightness level, a little of the colour showing through.
|
||||
let accent = c.map(|v| 0.9 * level + 0.1 * v);
|
||||
*p = premultiply(mix(c, accent, e), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Angled Strokes: the object repainted in diagonal strokes `len` long, light areas in right
|
||||
/// diagonal strokes and dark areas in left diagonal ones; `balance` (0..100) moves the split
|
||||
/// (0 all left diagonal, 100 all right diagonal) and `sharpness` (0..10) brings the detail and
|
||||
/// the bristles out.
|
||||
pub(super) fn angled_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, len: f64, sharpness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let split = 1.07 - 1.14 * (balance / 100.0).clamp(0.0, 1.0);
|
||||
let sharp = sharpness.clamp(0.0, 10.0);
|
||||
let (detail, bristle) = ((0.05 * sharp) as f32, 0.03 + 0.012 * sharp);
|
||||
let row = (0.2 * smear.len).max(1.0);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
// The split between the directions wanders along the strokes, so they interlock.
|
||||
let right = tones.get(x, y) > split + 0.06 * streak(SALT_ANGLED + 3, q, RIGHT, row, row);
|
||||
let (dir, salt) = if right { (RIGHT, SALT_ANGLED) } else { (LEFT, SALT_ANGLED + 1) };
|
||||
let pm = smear.stroke(salt, q, dir, row);
|
||||
let pm = mix(pm, unit(src.get(y * w + x).copied().unwrap_or([0; 4])), detail);
|
||||
let (c, a) = unpremultiply(pm);
|
||||
let n = (bristle * streak(SALT_ANGLED + 2, q, dir, 0.5 * smear.len.max(1.0), 0.6)) as f32;
|
||||
*p = premultiply(c.map(|v| v + n), a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Crosshatch: the object's detail kept under pencil hatching along both diagonals, strokes `len`
|
||||
/// long, roughening its edges; `sharpness` (0..20) sets how crisp and dark the hatching is,
|
||||
/// `passes` (1..3) how many layers of it there are.
|
||||
pub(super) fn crosshatch(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, sharpness: f64, passes: u32) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let sharp = sharpness.clamp(0.0, 20.0);
|
||||
let (amount, crisp) = (0.05 + 0.01 * sharp, 1.0 + 0.3 * sharp);
|
||||
let row = (0.2 * smear.len).max(1.0);
|
||||
let roughen = rough(smear.len);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let line = |k: u32| {
|
||||
let dir = if k.is_multiple_of(2) { RIGHT } else { LEFT };
|
||||
streak(SALT_CROSS + u64::from(k), q, dir, smear.len.max(1.0), 0.7 + 0.3 * f64::from(k))
|
||||
};
|
||||
let first = line(0);
|
||||
// The edges roughened along the first hatching.
|
||||
let o = q + RIGHT * (roughen * first);
|
||||
let strokes = mix(smear.stroke(SALT_CROSS + 4, o, RIGHT, row), smear.stroke(SALT_CROSS + 5, o, LEFT, row), 0.5);
|
||||
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), strokes, 0.5);
|
||||
let (mut c, a) = unpremultiply(pm);
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let shade = 1.3 - tones.get(x, y);
|
||||
for k in 0..passes.clamp(1, 3) {
|
||||
let v = (if k == 0 { first } else { line(k) } * crisp).clamp(-1.0, 1.0);
|
||||
// Dark pencil lines and the light paper between them.
|
||||
let s = if v > 0.0 { -(amount * v * shade) } else { -0.6 * amount * v };
|
||||
let s = s.clamp(-1.0, 1.0) as f32;
|
||||
c = c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s });
|
||||
}
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Dark Strokes: dark areas painted closer to black with short strokes, light areas with long
|
||||
/// strokes closer to white; `balance` (0..10) moves the tone between them up, `black` (0..10) and
|
||||
/// `white` (0..10) set how dark and how light they get.
|
||||
pub(super) fn dark_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, black: f64, white: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let short = Smear::new(px, w, h, &place, DARK_LENGTH);
|
||||
let long = Smear::new(px, w, h, &place, LIGHT_LENGTH);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let split = 0.15 + 0.07 * balance.clamp(0.0, 10.0);
|
||||
let (black, white) = (0.085 * black.clamp(0.0, 10.0), 0.085 * white.clamp(0.0, 10.0));
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let dark = 1.0 - ramp((tones.get(x, y) - split) / 0.12 + 0.5);
|
||||
let painted = |light: bool| {
|
||||
let (smear, dir, salt) = if light { (&long, RIGHT, SALT_DARK) } else { (&short, LEFT, SALT_DARK + 1) };
|
||||
let (c, a) = unpremultiply(smear.stroke(salt, q, dir, 1.0));
|
||||
let n = 0.7 + 0.3 * streak(salt + 2, q, dir, 0.5 * smear.len, 0.5);
|
||||
let c = if light {
|
||||
let s = (white * n).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v + (1.0 - v) * s)
|
||||
} else {
|
||||
let s = (black * n).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v * (1.0 - s))
|
||||
};
|
||||
[c[0] * a, c[1] * a, c[2] * a, a]
|
||||
};
|
||||
let pm = if dark >= 1.0 {
|
||||
painted(false)
|
||||
} else if dark <= 0.0 {
|
||||
painted(true)
|
||||
} else {
|
||||
mix(painted(true), painted(false), dark as f32)
|
||||
};
|
||||
let (c, a) = unpremultiply(pm);
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ink Outlines: the object redrawn in pen and ink, fine outlines over its edges and ink strokes
|
||||
/// `len` long in its shadows; `dark` (0..50) darkens the shadows, `light` (0..50) lightens the
|
||||
/// highlights.
|
||||
pub(super) fn ink_outlines(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, dark: f64, light: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let found = edges(px, w, h, &place, INK_SOFTNESS);
|
||||
let (dark, light) = ((dark / 50.0).clamp(0.0, 1.0), (light / 50.0).clamp(0.0, 1.0));
|
||||
let row = (0.25 * smear.len).max(0.75);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), smear.stroke(SALT_INK, q, LEFT, row), 0.5);
|
||||
let (c, a) = unpremultiply(pm);
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let l = f64::from(luma(c));
|
||||
let (shadow, highlight) = (((0.75 - l) / 0.75).clamp(0.0, 1.0), ((l - 0.45) / 0.55).clamp(0.0, 1.0));
|
||||
let (down, up) = ((0.7 * dark * shadow) as f32, (0.7 * light * highlight) as f32);
|
||||
let c = c.map(|v| v * (1.0 - down)).map(|v| v + (1.0 - v) * up);
|
||||
// Ink lines along the strokes in the shadows, and the outlines.
|
||||
let hatch = ramp(2.0 * streak(SALT_INK + 1, q, LEFT, 1.5 * smear.len.max(1.0), 0.5) + 0.2) * shadow * (0.4 + 1.2 * dark);
|
||||
let outline = (1.5 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0)) - 0.15).clamp(0.0, 1.0);
|
||||
let ink = (0.9 * outline).max(0.8 * hatch).min(1.0) as f32;
|
||||
*p = premultiply(c.map(|v| v * (1.0 - ink)), a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Spatter: the object as if sprayed with a spatter airbrush, each pixel taking the colour of one
|
||||
/// up to [`spatter_shift`] away; `smoothness` (1..15) makes the spatter's grains larger and
|
||||
/// smoother.
|
||||
pub(super) fn spatter(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, radius: f64, smoothness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let grain = 0.3 + 0.15 * smoothness.clamp(1.0, 15.0);
|
||||
let most = spatter_shift(radius);
|
||||
displace(px, w, h, &place, most, |q| {
|
||||
let n = |salt: u64| 0.6 * smooth_noise(salt, q, grain) + 0.4 * smooth_noise(salt + 2, q, 0.4 * grain);
|
||||
Vec2::new(n(SALT_SPATTER), n(SALT_SPATTER + 1)) * (1.8 * most)
|
||||
});
|
||||
}
|
||||
|
||||
/// Sprayed Strokes: the object repainted in sprayed strokes `len` long along `dir`, scattered by
|
||||
/// up to [`spray_shift`].
|
||||
pub(super) fn sprayed_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, radius: f64, dir: StrokeDirection) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let (dir, spray) = (dir.along(), spray_shift(radius));
|
||||
let along = (0.5 * smear.len).max(0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let n = |salt: u64| streak(salt, q, dir, along, 0.5);
|
||||
let mut d = Vec2::new(n(SALT_SPRAYED), n(SALT_SPRAYED + 1)) * (1.6 * spray);
|
||||
let l = d.hypot();
|
||||
if l > spray && l.is_finite() {
|
||||
d *= spray / l;
|
||||
}
|
||||
let (c, a) = unpremultiply(smear.stroke(SALT_SPRAYED + 2, q + d, dir, (0.2 * smear.len).max(1.0)));
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sumi-e: the object as if painted with a wet brush on rice paper, in strokes `width` (3..15)
|
||||
/// across with soft, bleeding edges and rich blacks; `pressure` (0..15) loads the brush with more
|
||||
/// ink and `contrast` (0..40) sets the contrast. It works in the ink (the value): the colours keep
|
||||
/// their hue and saturation, and the object keeps its shape.
|
||||
pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, contrast: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, width);
|
||||
// The ink around each pixel: the object's darkness by its coverage, softened.
|
||||
let mut wet: Vec<f32> = px.iter().map(|p| (1.0 - luma(straight(*p))) * f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut wet, w, h, 0.2 * smear.len / place.px);
|
||||
let load = 1.0 + 0.06 * pressure.clamp(0.0, 15.0);
|
||||
// How far the ink moves along an S curve: up to once at 20, twice at 40.
|
||||
let gain = contrast.clamp(0.0, 40.0) / 20.0;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (c, a) = unpremultiply(smear.stroke(SALT_SUMI, q, LEFT, (0.3 * smear.len).max(1.0)));
|
||||
// Where the stroke brings no paint, the pixel's own colour.
|
||||
let c = if a > 0.0 { c } else { straight(*p) };
|
||||
let l = f64::from(luma(c));
|
||||
// The ink bleeds into the paper around the darks, along its fibres.
|
||||
let bleed = 0.85 * f64::from(wet.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let fibre = 1.0 + 0.1 * streak(SALT_SUMI + 1, q, Vec2::new(1.0, 0.0), 2.0, 0.35);
|
||||
let mut ink = ((1.0 - l).max(bleed) * load * fibre).clamp(0.0, 1.0);
|
||||
let mut left = gain;
|
||||
while left > 0.0 {
|
||||
ink += (ramp(ink) - ink) * left.min(1.0);
|
||||
left -= 1.0;
|
||||
}
|
||||
let tone = (1.0 - ink).clamp(0.0, 1.0);
|
||||
// Darker towards black, lighter towards the paper's white: the hue stays.
|
||||
let out = if tone <= l {
|
||||
let k = if l > 1e-3 { (tone / l) as f32 } else { 0.0 };
|
||||
c.map(|v| v * k)
|
||||
} else {
|
||||
let k = ((tone - 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, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Effect › Distort (Diffuse Glow, Glass, Ocean Ripple).
|
||||
//!
|
||||
//! Glass and Ocean Ripple move each pixel's content by a shift that a surface decides (the slope
|
||||
//! of Glass's texture, smooth noise for the ripples), sampling the object bilinearly. The shift is
|
||||
//! capped, so the object reaches no farther than [`glass_shift`] or [`ripple_shift`] past its
|
||||
//! bounds. Diffuse Glow brightens the object's highlights towards white and sprinkles it with
|
||||
//! white grain. Every surface lies in document space around the object's centre, as the Texture
|
||||
//! filters' do.
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::pixelate::{Place, value_noise};
|
||||
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_GLOW: u64 = 120;
|
||||
const SALT_FROST: u64 = 128;
|
||||
const SALT_RIPPLE: u64 = 136;
|
||||
|
||||
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
|
||||
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
|
||||
|
||||
/// A Glass surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlassTexture {
|
||||
Blocks,
|
||||
Canvas,
|
||||
Frosted,
|
||||
TinyLens,
|
||||
}
|
||||
|
||||
impl GlassTexture {
|
||||
const ALL: [GlassTexture; 4] = [GlassTexture::Blocks, GlassTexture::Canvas, GlassTexture::Frosted, GlassTexture::TinyLens];
|
||||
|
||||
/// The surface of `value` (a [`GLASS_TEXTURES`] value, any case); Frosted otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
match self {
|
||||
GlassTexture::Blocks => blocks(p),
|
||||
GlassTexture::Canvas => Texture::Canvas.height(p),
|
||||
GlassTexture::Frosted => frosted(p),
|
||||
GlassTexture::TinyLens => tiny_lens(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Square glass blocks 10 points across, flat on top with 2.5-point bevels.
|
||||
fn blocks(p: Point) -> f64 {
|
||||
const SIZE: f64 = 10.0;
|
||||
const BEVEL: f64 = 2.5;
|
||||
let to_edge = |v: f64| {
|
||||
let t = v.rem_euclid(SIZE);
|
||||
t.min(SIZE - t)
|
||||
};
|
||||
to_edge(p.x).min(to_edge(p.y)).min(BEVEL)
|
||||
}
|
||||
|
||||
/// Frosted glass: two octaves of fine smooth noise (1.2 and 0.5 points).
|
||||
fn frosted(p: Point) -> f64 {
|
||||
0.8 * value_noise(SALT_FROST, p.x / 1.2, p.y / 1.2) + 0.2 * value_noise(SALT_FROST + 1, p.x / 0.5, p.y / 0.5)
|
||||
}
|
||||
|
||||
/// Tiny lenses 4 points across in rows and columns, each a shallow dome.
|
||||
fn tiny_lens(p: Point) -> f64 {
|
||||
const SIZE: f64 = 4.0;
|
||||
let r = 0.5 * SIZE;
|
||||
let off = |v: f64| v.rem_euclid(SIZE) - r;
|
||||
let d2 = off(p.x).powi(2) + off(p.y).powi(2);
|
||||
0.5 * r * (1.0 - d2 / (r * r)).max(0.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Glass gives at `distortion` (0..20).
|
||||
pub(super) fn glass_shift(distortion: f64) -> f64 {
|
||||
0.5 * distortion.clamp(0.0, 20.0)
|
||||
}
|
||||
|
||||
/// The ripples' size (points) for Ripple Size `size` (1..15).
|
||||
fn ripple_wave(size: f64) -> f64 {
|
||||
2.0 + size.clamp(1.0, 15.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Ocean Ripple gives for Ripple Size `size` (1..15) and Ripple
|
||||
/// Magnitude `magnitude` (0..20).
|
||||
pub(super) fn ripple_shift(size: f64, magnitude: f64) -> f64 {
|
||||
0.03 * magnitude.clamp(0.0, 20.0) * ripple_wave(size)
|
||||
}
|
||||
|
||||
/// How far (points) Diffuse Glow spreads the highlights (the σ of their blur).
|
||||
pub(super) const GLOW_SPREAD: f64 = 2.0;
|
||||
|
||||
/// Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2), bent by
|
||||
/// `distortion` (0..20) and smoothed by `smoothness` (1..15); `invert` turns the surface's heights
|
||||
/// over.
|
||||
#[allow(clippy::too_many_arguments)] // the effect's five options
|
||||
pub(super) fn glass(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
distortion: f64,
|
||||
smoothness: f64,
|
||||
texture: GlassTexture,
|
||||
scaling: f64,
|
||||
invert: bool,
|
||||
) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(scaling.is_finite() && scaling > 0.0 && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
// Slopes over a quarter point of the surface per step of Smoothness, or half a pixel where
|
||||
// pixels are larger: a smoother glass bends the image more gently.
|
||||
let step = (0.25 * smoothness.clamp(1.0, 15.0)).max(0.5 * place.px / scaling);
|
||||
let gain = 0.25 * distortion.clamp(0.0, 20.0) * if invert { -1.0 } else { 1.0 };
|
||||
displace(px, w, h, &place, glass_shift(distortion), |q| {
|
||||
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||
Vec2::new(at(step, 0.0) - at(-step, 0.0), at(0.0, step) - at(0.0, -step)) * (gain / (2.0 * step))
|
||||
});
|
||||
}
|
||||
|
||||
/// Ocean Ripple: the object as if under rippling water, ripples about `size` (1..15) points plus
|
||||
/// two across, shifting it by up to [`ripple_shift`].
|
||||
pub(super) fn ocean_ripple(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, magnitude: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let (wave, most) = (ripple_wave(size), ripple_shift(size, magnitude));
|
||||
displace(px, w, h, &place, most, |q| {
|
||||
let n = |salt: u64| 0.7 * smooth_noise(salt, q, wave) + 0.3 * smooth_noise(salt + 2, q, 0.5 * wave);
|
||||
Vec2::new(n(SALT_RIPPLE), n(SALT_RIPPLE + 1)) * most
|
||||
});
|
||||
}
|
||||
|
||||
/// Diffuse Glow: the object as seen through a soft diffusion filter. Its highlights, softened over
|
||||
/// [`GLOW_SPREAD`], glow white as strongly as `glow` (0..20) says, from the brightness that
|
||||
/// `clear` (0..20) leaves clear of glow; white grain as dense as `graininess` (0..10) says is
|
||||
/// sprinkled over it, thicker in the glow.
|
||||
pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, graininess: f64, glow: f64, clear: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let mut light: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut light, w, h, GLOW_SPREAD / place.px);
|
||||
let amount = (glow / 20.0).clamp(0.0, 1.0) as f32;
|
||||
let from = (0.9 * clear / 20.0).clamp(0.0, 0.9) as f32;
|
||||
let grain = (graininess / 10.0).clamp(0.0, 1.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let g = ((light.get(y * w + x).copied().unwrap_or(0.0) - from) / (1.0 - from)).clamp(0.0, 1.0);
|
||||
let chance = 0.5 * (cell_noise(SALT_GLOW, place.at(x, y), 1.0, 0) + 1.0);
|
||||
let dot = if chance < grain * (0.08 + 0.32 * f64::from(g)) { 0.55 } else { 0.0 };
|
||||
let white = (amount * g + dot).min(1.0);
|
||||
*p = premultiply(straight(*p).map(|v| v + (1.0 - v) * white), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the
|
||||
/// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it,
|
||||
/// interpolated between pixels, transparent beyond the raster.
|
||||
pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
|
||||
if !(most.is_finite() && most > 0.0) {
|
||||
return;
|
||||
}
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let d = shift(q);
|
||||
if !(d.x.is_finite() && d.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let len = d.hypot();
|
||||
let d = if len > most { d * (most / len) } else { d };
|
||||
let s = place.to_px * (q + d);
|
||||
*p = sample(&src, w, h, s.x - 0.5, s.y - 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
/// The premultiplied colour at (`x`, `y`) of `w` × `h` pixels (pixel centres at whole
|
||||
/// coordinates), interpolated between the four around it, transparent outside.
|
||||
fn sample(src: &[[u8; 4]], w: usize, h: usize, x: f64, y: f64) -> [u8; 4] {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return [0; 4];
|
||||
}
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = (x - x0, y - y0);
|
||||
let at = |i: f64, j: f64| -> [f64; 4] {
|
||||
if i < 0.0 || j < 0.0 || i >= w as f64 || j >= h as f64 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
src.get(j as usize * w + i as usize).map_or([0.0; 4], |p| p.map(f64::from))
|
||||
};
|
||||
let (a, b, c, d) = (at(x0, y0), at(x0 + 1.0, y0), at(x0, y0 + 1.0), at(x0 + 1.0, y0 + 1.0));
|
||||
std::array::from_fn(|k| {
|
||||
let top = a[k] + (b[k] - a[k]) * fx;
|
||||
let bottom = c[k] + (d[k] - c[k]) * fx;
|
||||
(top + (bottom - top) * fy).round().clamp(0.0, 255.0) as u8
|
||||
})
|
||||
}
|
||||
@@ -21,38 +21,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
|
||||
if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] };
|
||||
(straight, alpha)
|
||||
};
|
||||
let signal = |x: isize, y: isize| {
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
|
||||
};
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
let signal: Vec<f32> = (0..px.len())
|
||||
.map(|i| {
|
||||
let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize);
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
|
||||
})
|
||||
.collect();
|
||||
let coverage: Vec<f32> = src.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let mut edges: Vec<f32> = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect();
|
||||
let mut colours: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0.0; px.len()]);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let x = x as isize;
|
||||
let y = y as isize;
|
||||
let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1)
|
||||
+ signal(x - 1, y + 1)
|
||||
+ 2.0 * signal(x, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let alpha_signal = |x, y| pixel(x, y).1;
|
||||
let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y)
|
||||
- alpha_signal(x - 1, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1)
|
||||
+ alpha_signal(x - 1, y + 1)
|
||||
+ 2.0 * alpha_signal(x, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let index = y as usize * w + x as usize;
|
||||
let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0);
|
||||
if let Some(value) = edges.get_mut(index) {
|
||||
*value = edge;
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
for channel in 0..3 {
|
||||
colours[channel][index] = rgb[channel] * alpha * edge;
|
||||
}
|
||||
for (index, edge) in edges.iter().enumerate() {
|
||||
let (rgb, alpha) = pixel((index % w) as isize, (index / w) as isize);
|
||||
for (colour, v) in colours.iter_mut().zip(rgb) {
|
||||
if let Some(c) = colour.get_mut(index) {
|
||||
*c = v * alpha * edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,3 +67,25 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the
|
||||
/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match.
|
||||
pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
if w == 0 || w.checked_mul(h) != Some(v.len()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 {
|
||||
match (x.checked_add_signed(dx), y.checked_add_signed(dy)) {
|
||||
(Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
(0..v.len())
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1);
|
||||
let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1);
|
||||
gx.hypot(gy)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
|
||||
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
|
||||
//! Edges, and Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, 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, 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.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
@@ -9,29 +12,54 @@
|
||||
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod artistic;
|
||||
mod blur;
|
||||
mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{num, text};
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GlassTexture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 10] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"artistic.filmGrain",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
"brushStrokes.angledStrokes",
|
||||
"brushStrokes.crosshatch",
|
||||
"brushStrokes.darkStrokes",
|
||||
"brushStrokes.inkOutlines",
|
||||
"brushStrokes.spatter",
|
||||
"brushStrokes.sprayedStrokes",
|
||||
"brushStrokes.sumiE",
|
||||
"distort.diffuseGlow",
|
||||
"distort.glass",
|
||||
"distort.oceanRipple",
|
||||
"pixelate.colorHalftone",
|
||||
"pixelate.crystallize",
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
"texture.mosaicTiles",
|
||||
"texture.patchwork",
|
||||
"texture.stainedGlass",
|
||||
"texture.texturizer",
|
||||
"video.deinterlace",
|
||||
"video.ntscColors",
|
||||
];
|
||||
@@ -40,6 +68,9 @@ pub const PIXEL_EFFECTS: [&str; 10] = [
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// 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 },
|
||||
/// 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 },
|
||||
@@ -47,6 +78,42 @@ pub enum PixelFx {
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Brush Strokes › Angled Strokes: diagonal strokes `length` long, the light areas' and the
|
||||
/// dark areas' going opposite ways, split as `balance` (0..100) says, as crisp as `sharpness`
|
||||
/// (0..10).
|
||||
AngledStrokes { balance: f64, length: f64, sharpness: f64 },
|
||||
/// Brush Strokes › Crosshatch: pencil hatching `length` long along both diagonals, as crisp
|
||||
/// as `sharpness` (0..20), in `strength` (1..3) passes.
|
||||
Crosshatch { length: f64, sharpness: f64, strength: u32 },
|
||||
/// Brush Strokes › Dark Strokes: short strokes towards black in the dark areas, long ones
|
||||
/// towards white in the light areas, split by `balance` (0..10), as dark as `black` (0..10)
|
||||
/// and as light as `white` (0..10).
|
||||
DarkStrokes { balance: f64, black: f64, white: f64 },
|
||||
/// Brush Strokes › Ink Outlines: pen-and-ink outlines and strokes `length` long, the shadows
|
||||
/// darkened by `dark` (0..50) and the highlights lightened by `light` (0..50).
|
||||
InkOutlines { length: f64, dark: f64, light: f64 },
|
||||
/// Brush Strokes › Spatter: a spatter airbrush spraying within `radius` (0..25), its grains as
|
||||
/// smooth as `smoothness` (1..15).
|
||||
Spatter { radius: f64, smoothness: f64 },
|
||||
/// Brush Strokes › Sprayed Strokes: sprayed strokes `length` long along `direction`, scattered
|
||||
/// as `radius` (0..25) says.
|
||||
SprayedStrokes { length: f64, radius: f64, direction: StrokeDirection },
|
||||
/// Brush Strokes › Sumi-e: wet black brush strokes `width` (3..15) across on rice paper, as
|
||||
/// loaded as `pressure` (0..15) says, with `contrast` (0..40).
|
||||
SumiE { width: f64, pressure: f64, contrast: f64 },
|
||||
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
|
||||
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
|
||||
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
|
||||
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
|
||||
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
|
||||
/// `invert`.
|
||||
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
|
||||
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
|
||||
/// `magnitude` (0..20) says.
|
||||
OceanRipple { size: f64, magnitude: f64 },
|
||||
/// 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 },
|
||||
@@ -62,6 +129,24 @@ pub enum PixelFx {
|
||||
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
|
||||
/// canvas.
|
||||
Pointillize { cell: f64 },
|
||||
/// Texture › Craquelure: cracked relief plaster, plates about `spacing` apart, cracks as deep
|
||||
/// as `depth` (0..10), lit as brightly as `brightness` (0..10).
|
||||
Craquelure { spacing: f64, depth: f64, brightness: f64 },
|
||||
/// Texture › Grain: noise of `kind` as strong as `intensity` (0..100), the image's contrast
|
||||
/// set by `contrast` (0..100, 50 leaves it).
|
||||
Grain { intensity: f64, contrast: f64, kind: Grain },
|
||||
/// Texture › Mosaic Tiles: irregular tiles about `tile` across with sunken grout `grout` / 2
|
||||
/// wide (1..15), lightened by `lighten` (0..10).
|
||||
MosaicTiles { tile: f64, grout: f64, lighten: f64 },
|
||||
/// Texture › Patchwork: squares `square` across of the colour around their centres, raised by
|
||||
/// up to `relief` (0..25).
|
||||
Patchwork { square: f64, relief: f64 },
|
||||
/// Texture › Stained Glass: panes about `cell` across leaded `border` / 2 wide (1..20) in
|
||||
/// black, lit at the centre by `light` (0..10).
|
||||
StainedGlass { cell: f64, border: f64, light: f64 },
|
||||
/// Texture › Texturizer: a `texture` surface scaled by `scaling` (0.5..2), in relief as strong
|
||||
/// as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
|
||||
Texturizer { texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Video › De-Interlace: the odd (or `even`) field lines made again from the others, by
|
||||
/// duplication or, with `interpolate`, by averaging.
|
||||
DeInterlace { even: bool, interpolate: bool },
|
||||
@@ -100,6 +185,9 @@ pub struct PixelSpace {
|
||||
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
|
||||
/// Effects Settings › Resolution), the fields De-Interlace works on.
|
||||
pub line: f64,
|
||||
/// How far the corners of the object's bounds lie from its centre (document units): Stained
|
||||
/// Glass's light fades out over it.
|
||||
pub extent: f64,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
@@ -109,6 +197,11 @@ 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.filmGrain" => PixelFx::FilmGrain {
|
||||
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.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"),
|
||||
@@ -119,6 +212,59 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
},
|
||||
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 38.0).clamp(0.0, 50.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"brushStrokes.angledStrokes" => PixelFx::AngledStrokes {
|
||||
balance: num(p, "directionBalance", 50.0).clamp(0.0, 100.0),
|
||||
length: num(p, "strokeLength", 15.0).clamp(3.0, 50.0),
|
||||
sharpness: num(p, "sharpness", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"brushStrokes.crosshatch" => PixelFx::Crosshatch {
|
||||
length: num(p, "strokeLength", 9.0).clamp(3.0, 50.0),
|
||||
sharpness: num(p, "sharpness", 6.0).clamp(0.0, 20.0),
|
||||
strength: num(p, "strength", 1.0).clamp(1.0, 3.0).round() as u32,
|
||||
},
|
||||
"brushStrokes.darkStrokes" => PixelFx::DarkStrokes {
|
||||
balance: num(p, "balance", 5.0).clamp(0.0, 10.0),
|
||||
black: num(p, "blackIntensity", 6.0).clamp(0.0, 10.0),
|
||||
white: num(p, "whiteIntensity", 2.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"brushStrokes.inkOutlines" => PixelFx::InkOutlines {
|
||||
length: num(p, "strokeLength", 4.0).clamp(1.0, 50.0),
|
||||
dark: num(p, "darkIntensity", 20.0).clamp(0.0, 50.0),
|
||||
light: num(p, "lightIntensity", 10.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"brushStrokes.spatter" => {
|
||||
PixelFx::Spatter { radius: num(p, "sprayRadius", 10.0).clamp(0.0, 25.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0) }
|
||||
}
|
||||
"brushStrokes.sprayedStrokes" => PixelFx::SprayedStrokes {
|
||||
length: num(p, "strokeLength", 12.0).clamp(0.0, 20.0),
|
||||
radius: num(p, "sprayRadius", 7.0).clamp(0.0, 25.0),
|
||||
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
|
||||
},
|
||||
"brushStrokes.sumiE" => PixelFx::SumiE {
|
||||
width: num(p, "strokeWidth", 10.0).clamp(3.0, 15.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 15.0),
|
||||
contrast: num(p, "contrast", 16.0).clamp(0.0, 40.0),
|
||||
},
|
||||
"distort.diffuseGlow" => PixelFx::DiffuseGlow {
|
||||
graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0),
|
||||
glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0),
|
||||
clear: num(p, "clearAmount", 15.0).clamp(0.0, 20.0),
|
||||
},
|
||||
"distort.glass" => PixelFx::Glass {
|
||||
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
texture: GlassTexture::parse(text(p, "texture", "frosted")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"distort.oceanRipple" => {
|
||||
PixelFx::OceanRipple { size: num(p, "rippleSize", 9.0).clamp(1.0, 15.0), magnitude: num(p, "rippleMagnitude", 9.0).clamp(0.0, 20.0) }
|
||||
}
|
||||
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
|
||||
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
@@ -137,6 +283,36 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
"pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) },
|
||||
"pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) },
|
||||
"pixelate.pointillize" => PixelFx::Pointillize { cell: num(p, "cellSize", 5.0).clamp(3.0, 300.0) },
|
||||
"texture.craquelure" => PixelFx::Craquelure {
|
||||
spacing: num(p, "crackSpacing", 15.0).clamp(2.0, 100.0),
|
||||
depth: num(p, "crackDepth", 6.0).clamp(0.0, 10.0),
|
||||
brightness: num(p, "crackBrightness", 9.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.grain" => PixelFx::Grain {
|
||||
intensity: num(p, "intensity", 40.0).clamp(0.0, 100.0),
|
||||
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
|
||||
kind: Grain::parse(text(p, "grainType", "regular")),
|
||||
},
|
||||
"texture.mosaicTiles" => PixelFx::MosaicTiles {
|
||||
tile: num(p, "tileSize", 12.0).clamp(2.0, 100.0),
|
||||
grout: num(p, "groutWidth", 3.0).clamp(1.0, 15.0),
|
||||
lighten: num(p, "lightenGrout", 9.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.patchwork" => {
|
||||
PixelFx::Patchwork { square: num(p, "squareSize", 4.0).clamp(0.0, 10.0), relief: num(p, "relief", 8.0).clamp(0.0, 25.0) }
|
||||
}
|
||||
"texture.stainedGlass" => PixelFx::StainedGlass {
|
||||
cell: num(p, "cellSize", 10.0).clamp(2.0, 50.0),
|
||||
border: num(p, "borderThickness", 4.0).clamp(1.0, 20.0),
|
||||
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.texturizer" => PixelFx::Texturizer {
|
||||
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),
|
||||
},
|
||||
"video.deinterlace" => PixelFx::DeInterlace {
|
||||
even: text(p, "eliminate", "odd").eq_ignore_ascii_case("even"),
|
||||
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
|
||||
@@ -157,11 +333,34 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
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::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
|
||||
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
|
||||
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
|
||||
PixelFx::SprayedStrokes { length, radius, .. } => brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius),
|
||||
// Sumi-e keeps the object's shape.
|
||||
PixelFx::SumiE { .. } => 0.0,
|
||||
PixelFx::DiffuseGlow { .. } => 0.0,
|
||||
// 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 { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } | PixelFx::DeInterlace { .. } | PixelFx::NtscColors => 0.0,
|
||||
// A crystal's or dot's point inside the object reaches out by up to its cell.
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } => 1.5 * cell,
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
| PixelFx::Grain { .. }
|
||||
| PixelFx::MosaicTiles { .. }
|
||||
| PixelFx::Texturizer { .. }
|
||||
| PixelFx::DeInterlace { .. }
|
||||
| PixelFx::NtscColors => 0.0,
|
||||
// A crystal's, dot's or pane's point inside the object reaches out by up to its cell.
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
|
||||
// A square whose centre is on the object is drawn whole.
|
||||
PixelFx::Patchwork { square, .. } => square.max(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,14 +370,34 @@ impl PixelFx {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
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::Crosshatch { length, .. } => {
|
||||
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
PixelFx::DarkStrokes { .. } => Some(brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH).max(brushstrokes::TONE_REACH)),
|
||||
PixelFx::InkOutlines { length, .. } => Some(brushstrokes::ink_reach(length)),
|
||||
PixelFx::Spatter { radius, .. } => Some(brushstrokes::spatter_shift(radius)),
|
||||
PixelFx::SprayedStrokes { length, radius, .. } => Some(brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius)),
|
||||
PixelFx::SumiE { width, .. } => Some(brushstrokes::sumi_reach(width)),
|
||||
PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD),
|
||||
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::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::Mezzotint { .. } => Some(0.0),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::Craquelure { spacing, .. } => Some(3.0 * texture::CONTOUR_SOFTNESS * spacing),
|
||||
PixelFx::Grain { .. } | PixelFx::MosaicTiles { .. } | PixelFx::Texturizer { .. } => Some(0.0),
|
||||
// The square's or pane's point, and the softened colour around it.
|
||||
PixelFx::Patchwork { square, .. } => Some(2.0 * square.max(1.0)),
|
||||
PixelFx::StainedGlass { cell, .. } => Some(2.5 * cell),
|
||||
// The lines above and below.
|
||||
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
|
||||
PixelFx::NtscColors => Some(0.0),
|
||||
@@ -191,20 +410,48 @@ 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::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
|
||||
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
|
||||
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
|
||||
PixelFx::DarkStrokes { balance, black, white } => brushstrokes::dark_strokes(px, w, h, space, balance, black, white),
|
||||
PixelFx::InkOutlines { length, dark, light } => brushstrokes::ink_outlines(px, w, h, space, length, dark, light),
|
||||
PixelFx::Spatter { radius, smoothness } => brushstrokes::spatter(px, w, h, space, radius, smoothness),
|
||||
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
|
||||
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
|
||||
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
|
||||
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
|
||||
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
|
||||
}
|
||||
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::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),
|
||||
PixelFx::Mezzotint { kind } => pixelate::mezzotint(px, w, h, space, kind),
|
||||
PixelFx::Pointillize { cell } => pixelate::pointillize(px, w, h, space, cell),
|
||||
PixelFx::Craquelure { spacing, depth, brightness } => texture::craquelure(px, w, h, space, spacing, depth, brightness),
|
||||
PixelFx::Grain { intensity, contrast, kind } => texture::grain(px, w, h, space, intensity, contrast, kind),
|
||||
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 } => {
|
||||
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
PixelFx::NtscColors => video::ntsc(px),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
|
||||
/// otherwise.
|
||||
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::{Channels, PixelSpace, gaussian, to16};
|
||||
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
|
||||
use crate::util::noise;
|
||||
|
||||
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
|
||||
@@ -62,7 +62,7 @@ impl Mezzotint {
|
||||
|
||||
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
MEZZOTINT_TYPES.iter().zip(MEZZOTINT_KINDS).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(Self::FINE_DOTS, |(_, kind)| kind)
|
||||
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,48 +70,48 @@ impl Mezzotint {
|
||||
const SEED: u64 = 0x5049_5845_4C41_5445;
|
||||
|
||||
/// A random number in [0, 1) for cell (`i`, `j`) of pattern `salt`; `k` picks one of several.
|
||||
fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
|
||||
pub(super) fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
|
||||
0.5 * (noise(SEED ^ salt, i as u64, j as u64, k) + 1.0)
|
||||
}
|
||||
|
||||
/// The cell holding coordinate `v` (in cells); `None` far beyond any raster (or not finite).
|
||||
fn cell(v: f64) -> Option<i64> {
|
||||
pub(super) fn cell(v: f64) -> Option<i64> {
|
||||
let f = v.floor();
|
||||
(f.abs() < 1e15).then_some(f as i64)
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie relative to the object's centre, in document units.
|
||||
struct Place {
|
||||
pub(super) struct Place {
|
||||
/// Pixel coordinates → document coordinates relative to the centre.
|
||||
to_rel: Affine,
|
||||
pub(super) to_rel: Affine,
|
||||
/// The other way.
|
||||
to_px: Affine,
|
||||
pub(super) to_px: Affine,
|
||||
/// A pixel's size in document units.
|
||||
px: f64,
|
||||
pub(super) px: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
|
||||
pub(super) fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
|
||||
let to_rel = Affine::translate(-space.center.to_vec2()) * space.to_doc;
|
||||
let to_px = to_rel.inverse();
|
||||
(to_rel.is_finite() && to_px.is_finite() && space.px.is_finite() && space.px > 0.0).then_some(Self { to_rel, to_px, px: space.px, w, h })
|
||||
}
|
||||
|
||||
/// The centre of pixel (`x`, `y`).
|
||||
fn at(&self, x: usize, y: usize) -> Point {
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> Point {
|
||||
self.to_rel * Point::new(x as f64 + 0.5, y as f64 + 0.5)
|
||||
}
|
||||
|
||||
/// The index of the pixel holding point `p`; `None` outside the raster.
|
||||
fn index(&self, p: Point) -> Option<usize> {
|
||||
pub(super) fn index(&self, p: Point) -> Option<usize> {
|
||||
let q = self.to_px * p;
|
||||
(q.x >= 0.0 && q.y >= 0.0 && q.x < self.w as f64 && q.y < self.h as f64).then(|| q.y as usize * self.w + q.x as usize)
|
||||
}
|
||||
|
||||
/// Run `f(x, y, pixel)` on every pixel.
|
||||
fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
|
||||
pub(super) fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
|
||||
for (y, row) in px.chunks_exact_mut(self.w.max(1)).enumerate().take(self.h) {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
f(x, y, p);
|
||||
@@ -122,7 +122,7 @@ impl Place {
|
||||
|
||||
/// The cells around a pixel's cell, in the order they can hold its nearest random point: the
|
||||
/// cell itself, its 8 neighbours, then the ring around them.
|
||||
const AROUND: [(i64, i64); 25] = [
|
||||
pub(super) const AROUND: [(i64, i64); 25] = [
|
||||
(0, 0),
|
||||
(-1, 0),
|
||||
(1, 0),
|
||||
@@ -152,18 +152,18 @@ const AROUND: [(i64, i64); 25] = [
|
||||
|
||||
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
|
||||
/// pixels stay in that cell.
|
||||
struct Around<T> {
|
||||
pub(super) struct Around<T> {
|
||||
cell: Option<(i64, i64)>,
|
||||
items: [Option<T>; 25],
|
||||
}
|
||||
|
||||
impl<T: Copy> Around<T> {
|
||||
fn new() -> Self {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { cell: None, items: [None; 25] }
|
||||
}
|
||||
|
||||
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
|
||||
fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
|
||||
pub(super) fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
|
||||
if self.cell != Some((i, j)) {
|
||||
self.cell = Some((i, j));
|
||||
self.items = [None; 25];
|
||||
@@ -175,10 +175,26 @@ impl<T: Copy> Around<T> {
|
||||
}
|
||||
|
||||
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
|
||||
fn point_in(salt: u64, i: i64, j: i64) -> Point {
|
||||
pub(super) fn point_in(salt: u64, i: i64, j: i64) -> Point {
|
||||
Point::new(i as f64 + rand(salt, i, j, 0), j as f64 + rand(salt, i, j, 1))
|
||||
}
|
||||
|
||||
/// How far cell `c` lies from coordinate `t` (in cells) along one axis.
|
||||
pub(super) fn gap(c: i64, t: f64) -> f64 {
|
||||
(c as f64 - t).max(t - c as f64 - 1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Smooth random values in [0, 1) of pattern `salt` at whole coordinates, blended between them.
|
||||
pub(super) fn value_noise(salt: u64, x: f64, y: f64) -> f64 {
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
|
||||
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
|
||||
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
|
||||
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
|
||||
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
|
||||
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
|
||||
top + (bottom - top) * fy
|
||||
}
|
||||
|
||||
/// Crystallize: every pixel takes the colour at the nearest of one random point per `cell` ×
|
||||
/// `cell` square (a Voronoi diagram over a jittered grid): polygon crystals of solid colour.
|
||||
pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
|
||||
@@ -188,8 +204,6 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
}
|
||||
let src = px.to_vec();
|
||||
let mut around = Around::new();
|
||||
// How far cell `c` lies from coordinate `t` (in cells) along one axis.
|
||||
let gap = |c: i64, t: f64| (c as f64 - t).max(t - c as f64 - 1.0).max(0.0);
|
||||
place.each(px, |x, y, out| {
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / cell_size, q.y / cell_size);
|
||||
@@ -215,6 +229,34 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
});
|
||||
}
|
||||
|
||||
/// The object's colours softened by a Gaussian of `sigma` pixels, with its coverage as it is: what
|
||||
/// the cell filters (Pointillize, Patchwork, Stained Glass) fill their cells with.
|
||||
pub(super) struct Soft {
|
||||
colour: Vec<Px16>,
|
||||
alpha: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Soft {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, sigma: f64) -> Self {
|
||||
let mut colour = to16(px);
|
||||
gaussian(&mut colour, w, sigma);
|
||||
Self { colour, alpha: px.iter().map(|p| p[3]).collect() }
|
||||
}
|
||||
|
||||
/// The colour around pixel `k` (premultiplied, 0..1), as opaque as the object is at `k`; clear
|
||||
/// outside the raster.
|
||||
pub(super) fn at(&self, k: Option<usize>) -> [f32; 4] {
|
||||
let a = k.and_then(|k| self.alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
|
||||
match k.and_then(|k| self.colour.get(k)) {
|
||||
Some(p) if p[3] > 0 => {
|
||||
let s = a / f32::from(p[3]);
|
||||
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of Pointillize's dots: where (in cells), its radius (document units), its stacking order
|
||||
/// and its colour (premultiplied, 0..1).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -237,20 +279,10 @@ pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
}
|
||||
// Each dot takes the colour around its centre, not of a single pixel there, and is as opaque
|
||||
// as the object at its centre (whole dots on the object, none beside it).
|
||||
let alpha: Vec<u8> = px.iter().map(|p| p[3]).collect();
|
||||
let mut soft = to16(px);
|
||||
gaussian(&mut soft, w, cell_size / place.px / 4.0);
|
||||
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
|
||||
let dot = |i: i64, j: i64| {
|
||||
let at = point_in(2, i, j);
|
||||
let k = place.index(Point::new(at.x * cell_size, at.y * cell_size));
|
||||
let a = k.and_then(|k| alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
|
||||
let colour = match k.and_then(|k| soft.get(k)) {
|
||||
Some(p) if p[3] > 0 => {
|
||||
let s = a / f32::from(p[3]);
|
||||
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let colour = soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
|
||||
Dot { at, radius: (DOT_RADIUS.0 + DOT_RADIUS.1 * rand(2, i, j, 2)) * cell_size, z: rand(2, i, j, 3), colour }
|
||||
};
|
||||
let mut around = Around::new();
|
||||
@@ -499,19 +531,6 @@ pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
|
||||
/// Mezzotint's threshold in [0, 1) at point `q` (document units around the centre) of pattern
|
||||
/// `salt`.
|
||||
fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
|
||||
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
|
||||
// Random values at whole coordinates, blended smoothly between them along x (and y).
|
||||
let blend = |x: f64, y: f64, along_y: bool| {
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
|
||||
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
|
||||
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
|
||||
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
|
||||
if !along_y {
|
||||
return top;
|
||||
}
|
||||
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
|
||||
top + (bottom - top) * fy
|
||||
};
|
||||
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
|
||||
(Some(i), Some(j)) => rand(salt, i, j, 1),
|
||||
_ => 0.5,
|
||||
@@ -520,12 +539,10 @@ fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
|
||||
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
|
||||
Mezzotint::Dots { size, grainy, .. } => {
|
||||
let s = f64::from(size);
|
||||
let blob = blend(q.x / s, q.y / s, true);
|
||||
let blob = value_noise(salt, q.x / s, q.y / s);
|
||||
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
|
||||
}
|
||||
Mezzotint::Lines { length, width } => {
|
||||
let row = (q.y / f64::from(width)).floor();
|
||||
blend(q.x / f64::from(length), row, false)
|
||||
}
|
||||
// Blended along x only: whole rows.
|
||||
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,640 @@
|
||||
//! Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer.
|
||||
//!
|
||||
//! Like the Pixelate filters' patterns, theirs (cracks, grain, tiles, squares, panes and the
|
||||
//! Texturizer's surfaces, all generated here, no image files) lie in document space around the
|
||||
//! object's centre: the same at any zoom or resolution, moving with the object, and the same on
|
||||
//! every redraw (hash noise of the pattern's cell indices). Surfaces in relief are height fields
|
||||
//! whose slopes, taken in document units, are lit from one side.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
/// Grain's types, in its Grain Type menu's order: (label, parameter value).
|
||||
pub const GRAIN_TYPES: [(&str, &str); 10] = [
|
||||
("Regular", "regular"),
|
||||
("Soft", "soft"),
|
||||
("Sprinkles", "sprinkles"),
|
||||
("Clumped", "clumped"),
|
||||
("Contrasty", "contrasty"),
|
||||
("Enlarged", "enlarged"),
|
||||
("Stippled", "stippled"),
|
||||
("Horizontal", "horizontal"),
|
||||
("Vertical", "vertical"),
|
||||
("Speckle", "speckle"),
|
||||
];
|
||||
|
||||
/// A Grain type ([`GRAIN_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Grain {
|
||||
Regular,
|
||||
Soft,
|
||||
Sprinkles,
|
||||
Clumped,
|
||||
Contrasty,
|
||||
Enlarged,
|
||||
Stippled,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Speckle,
|
||||
}
|
||||
|
||||
impl Grain {
|
||||
const ALL: [Grain; 10] = [
|
||||
Grain::Regular,
|
||||
Grain::Soft,
|
||||
Grain::Sprinkles,
|
||||
Grain::Clumped,
|
||||
Grain::Contrasty,
|
||||
Grain::Enlarged,
|
||||
Grain::Stippled,
|
||||
Grain::Horizontal,
|
||||
Grain::Vertical,
|
||||
Grain::Speckle,
|
||||
];
|
||||
|
||||
/// The type of Grain Type `value` (a [`GRAIN_TYPES`] value, any case); Regular otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&GRAIN_TYPES, &Self::ALL, value, Grain::Regular)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub enum Texture {
|
||||
Brick,
|
||||
Burlap,
|
||||
Canvas,
|
||||
Sandstone,
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
const ALL: [Texture; 4] = [Texture::Brick, Texture::Burlap, Texture::Canvas, Texture::Sandstone];
|
||||
|
||||
/// The texture of `value` (a [`TEXTURES`] value, any case); Canvas otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
match self {
|
||||
Texture::Brick => brick(p),
|
||||
Texture::Burlap => weave(p, 7.0, true),
|
||||
Texture::Canvas => weave(p, 4.0, false),
|
||||
Texture::Sandstone => sandstone(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texturizer's light directions, in its Light menu's order: (label, parameter value).
|
||||
pub const LIGHT_DIRECTIONS: [(&str, &str); 8] = [
|
||||
("Bottom", "bottom"),
|
||||
("Bottom Left", "bottomLeft"),
|
||||
("Left", "left"),
|
||||
("Top Left", "topLeft"),
|
||||
("Top", "top"),
|
||||
("Top Right", "topRight"),
|
||||
("Right", "right"),
|
||||
("Bottom Right", "bottomRight"),
|
||||
];
|
||||
|
||||
/// Where a surface is lit from ([`LIGHT_DIRECTIONS`]), on the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Light {
|
||||
Bottom,
|
||||
BottomLeft,
|
||||
Left,
|
||||
TopLeft,
|
||||
Top,
|
||||
TopRight,
|
||||
Right,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl Light {
|
||||
const ALL: [Light; 8] =
|
||||
[Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight];
|
||||
|
||||
/// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&LIGHT_DIRECTIONS, &Self::ALL, value, Light::Top)
|
||||
}
|
||||
|
||||
/// The unit vector towards the light (document axes, y down).
|
||||
fn toward(self) -> Vec2 {
|
||||
let d = FRAC_1_SQRT_2;
|
||||
match self {
|
||||
Light::Bottom => Vec2::new(0.0, 1.0),
|
||||
Light::BottomLeft => Vec2::new(-d, d),
|
||||
Light::Left => Vec2::new(-1.0, 0.0),
|
||||
Light::TopLeft => Vec2::new(-d, -d),
|
||||
Light::Top => Vec2::new(0.0, -1.0),
|
||||
Light::TopRight => Vec2::new(d, -d),
|
||||
Light::Right => Vec2::new(1.0, 0.0),
|
||||
Light::BottomRight => Vec2::new(d, d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds of the Texture filters' patterns (Pixelate's use smaller salts).
|
||||
const SALT_CRACKS: u64 = 64;
|
||||
const SALT_GRAIN: u64 = 72;
|
||||
const SALT_TILES: u64 = 88;
|
||||
const SALT_PATCH: u64 = 96;
|
||||
const SALT_GLASS: u64 = 104;
|
||||
const SALT_SURFACE: u64 = 112;
|
||||
|
||||
/// How soft Craquelure's tones are, as a share of the crack spacing: their contours become cracks.
|
||||
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
|
||||
|
||||
/// A pixel's straight colour (0..1).
|
||||
pub(super) fn straight(p: [u8; 4]) -> [f32; 3] {
|
||||
let a = f32::from(p[3]);
|
||||
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
|
||||
pub(super) fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
|
||||
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
|
||||
[r, g, b, a as u8]
|
||||
}
|
||||
|
||||
/// 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` (height per length) faces a light from
|
||||
/// `toward`: 0 flat, positive facing it, negative turned away.
|
||||
fn facing(slope: Vec2, toward: Vec2) -> f64 {
|
||||
-slope.dot(toward)
|
||||
}
|
||||
|
||||
/// The share of a pixel `px` wide that lies within `d` of a line (negative `d`: beyond it).
|
||||
fn cover(d: f64, px: f64) -> f64 {
|
||||
(d / px + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// The luma of colour `c` (Rec. 601 weights).
|
||||
pub(super) fn luma(c: [f32; 3]) -> f32 {
|
||||
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
}
|
||||
|
||||
/// Strict distance ordering, with the original hypot comparison at rounding/tie boundaries.
|
||||
fn distance_less(a2: f64, b2: f64, exact: impl FnOnce() -> (f64, f64)) -> bool {
|
||||
// Cell distances are small and finite. Preserve hypot's ordering at rounding/tie
|
||||
// boundaries, without computing it for every candidate of every pixel.
|
||||
if b2.is_finite() && (a2 - b2).abs() <= 8.0 * f64::EPSILON * (a2 + b2) {
|
||||
let (a, b) = exact();
|
||||
a < b
|
||||
} else {
|
||||
a2 < b2
|
||||
}
|
||||
}
|
||||
|
||||
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
|
||||
/// found among the cells [`AROUND`] the one holding it.
|
||||
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
|
||||
let (i, j) = (cell(u)?, cell(v)?);
|
||||
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
|
||||
for (k, (di, dj)) in AROUND.iter().enumerate() {
|
||||
// A cell farther than the second point so far can't hold a nearer one.
|
||||
let (gx, gy) = (gap(i.wrapping_add(*di), u), gap(j.wrapping_add(*dj), v));
|
||||
let exact = |p: Option<Point>| p.map_or(f64::INFINITY, |p| (p.x - u).hypot(p.y - v));
|
||||
if !distance_less(gx * gx + gy * gy, best[1].0, || (gx.hypot(gy), exact(best[1].1))) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = around.get((i, j), k, &point) else { continue };
|
||||
let d2 = (p.x - u) * (p.x - u) + (p.y - v) * (p.y - v);
|
||||
if distance_less(d2, best[0].0, || (exact(Some(p)), exact(best[0].1))) {
|
||||
best = [(d2, Some(p)), best[0]];
|
||||
} else if distance_less(d2, best[1].0, || (exact(Some(p)), exact(best[1].1))) {
|
||||
best[1] = (d2, Some(p));
|
||||
}
|
||||
}
|
||||
Some((best[0].1?, best[1].1?))
|
||||
}
|
||||
|
||||
/// How far (`u`, `v`) lies from the border between the cells of `near` and `next` (its nearest
|
||||
/// points, [`nearest_two`]), and the way away from it (unit), in cells.
|
||||
fn edge(u: f64, v: f64, near: Point, next: Point) -> (f64, Vec2) {
|
||||
let q = Point::new(u, v);
|
||||
let apart = near - next;
|
||||
let len = apart.hypot();
|
||||
if !(len.is_finite() && len > 1e-12) {
|
||||
return (0.0, Vec2::ZERO);
|
||||
}
|
||||
(((q - next).hypot2() - (q - near).hypot2()) / (2.0 * len), apart / len)
|
||||
}
|
||||
|
||||
/// The slope of a rise of 1 over `width` from a border at distance `d` (smooth at both ends).
|
||||
fn bevel(d: f64, width: f64) -> f64 {
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let t = (d / width).clamp(0.0, 1.0);
|
||||
6.0 * t * (1.0 - t)
|
||||
}
|
||||
|
||||
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
|
||||
/// coverage so the transparency around it doesn't darken its edges.
|
||||
pub(super) struct Tones {
|
||||
v: Vec<f32>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
|
||||
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
|
||||
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut l, w, h, sigma);
|
||||
blur_plane(&mut a, w, h, sigma);
|
||||
let v = l.iter().zip(&a).map(|(l, a)| if *a > 1e-4 { (l / a).clamp(0.0, 1.0) } else { 0.0 }).collect();
|
||||
Self { v, w, h }
|
||||
}
|
||||
|
||||
pub(super) fn get(&self, x: usize, y: usize) -> f64 {
|
||||
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
|
||||
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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.
|
||||
let [a, b, c, d, _, _] = place.to_px.as_coeffs();
|
||||
(self.get(x, y), Vec2::new(gx * a + gy * b, gx * c + gy * d))
|
||||
}
|
||||
}
|
||||
|
||||
/// Craquelure: the object painted on relief plaster that cracks into plates about `spacing` apart
|
||||
/// and along the contours of its tones. `depth` (0..10) sets how deep and wide the cracks are and
|
||||
/// how high the plates and the tones stand; `brightness` (0..10) how brightly the surface is lit.
|
||||
pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, spacing: f64, depth: f64, brightness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(spacing.is_finite() && spacing > 0.0) {
|
||||
return;
|
||||
}
|
||||
let depth = (depth / 10.0).clamp(0.0, 1.0);
|
||||
let light = (0.5 + 0.05 * brightness.clamp(0.0, 10.0)) as f32;
|
||||
let soft = CONTOUR_SOFTNESS * spacing;
|
||||
let tones = Tones::new(px, w, h, soft / place.px);
|
||||
let half = (0.15 + 0.6 * depth).min(0.15 * spacing);
|
||||
let rise = (3.0 * half).max(0.12 * spacing);
|
||||
let toward = Light::TopLeft.toward();
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / spacing, q.y / spacing);
|
||||
// The cracks between the plates; each plate's edge bevels down into them.
|
||||
let (mut crack, mut slope) = (0.0, Vec2::ZERO);
|
||||
if let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_CRACKS, i, j)) {
|
||||
let (e, away) = edge(u, v, near, next);
|
||||
crack = cover(half - e * spacing, place.px);
|
||||
slope = away * (0.6 * bevel(e * spacing - half, rise));
|
||||
}
|
||||
// The cracks along the tones' contours (quarter, half and three-quarter tone).
|
||||
let (tone, grad) = tones.slope(x, y, &place);
|
||||
let steep = grad.hypot();
|
||||
if steep > 1e-4 {
|
||||
let level = (tone * 4.0).round().clamp(1.0, 3.0) / 4.0;
|
||||
crack = crack.max(cover(0.8 * half - (tone - level).abs() / steep, place.px));
|
||||
}
|
||||
slope += grad * (0.8 * soft);
|
||||
let c = lit(straight(*p), 0.5 * depth * facing(slope, toward));
|
||||
let dark = (1.0 - 0.85 * crack * depth) as f32;
|
||||
*p = premultiply(c.map(|v| v * light * dark), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
|
||||
pub(super) fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
|
||||
match (cell(q.x / size), cell(q.y / size)) {
|
||||
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
|
||||
pub(super) fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
|
||||
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
|
||||
}
|
||||
|
||||
/// Grain: noise of `kind` over the object in 1-point grains (2 for Enlarged, clumps for Clumped),
|
||||
/// as strong as `intensity` (0..100); `contrast` (0..100, 50 leaves it) sets the image's
|
||||
/// contrast. Sprinkles and Stippled use the background colour, white.
|
||||
pub(super) fn grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, intensity: f64, contrast: f64, kind: Grain) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let a = (intensity / 100.0).clamp(0.0, 1.0);
|
||||
let k = (0.5 + contrast / 100.0).clamp(0.5, 1.5) as f32;
|
||||
let s = SALT_GRAIN;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let mut c = straight(*p).map(|v| (v - 0.5) * k + 0.5);
|
||||
let chance = 0.5 * (cell_noise(s, q, 1.0, 3) + 1.0);
|
||||
match kind {
|
||||
Grain::Regular => add(&mut c, |ch| cell_noise(s, q, 1.0, ch), 0.5 * a),
|
||||
Grain::Soft => add(&mut c, |ch| smooth_noise(s + ch, q, 1.0), 0.4 * a),
|
||||
Grain::Clumped => add(&mut c, |ch| 0.65 * smooth_noise(s + ch, q, 3.0) + 0.35 * cell_noise(s, q, 1.0, ch), 0.6 * a),
|
||||
Grain::Enlarged => add(&mut c, |ch| 0.5 * (smooth_noise(s + ch, q, 2.0) + cell_noise(s, q, 2.0, ch)), 0.5 * a),
|
||||
Grain::Contrasty => {
|
||||
c = c.map(|v| (v - 0.5) * 1.4 + 0.5);
|
||||
add(&mut c, |_| cell_noise(s, q, 1.0, 4), 0.6 * a);
|
||||
}
|
||||
Grain::Horizontal | Grain::Vertical => {
|
||||
// Streaks one point thick: a value per row (column) drifting along it.
|
||||
let (along, across) = if kind == Grain::Horizontal { (q.x, q.y) } else { (q.y, q.x) };
|
||||
let line = cell(across).unwrap_or(0);
|
||||
let n = 0.6 * (2.0 * rand(s, 0, line, 6) - 1.0) + 0.4 * (2.0 * value_noise(s + 7, along / 12.0, line as f64) - 1.0);
|
||||
add(&mut c, |_| n, 0.5 * a);
|
||||
}
|
||||
Grain::Sprinkles => {
|
||||
if chance < 0.35 * a {
|
||||
c = c.map(|v| v + (1.0 - v) * 0.9);
|
||||
}
|
||||
}
|
||||
Grain::Speckle => {
|
||||
if chance < 0.25 * a {
|
||||
c = c.map(|v| v * 0.25);
|
||||
} else {
|
||||
add(&mut c, |_| cell_noise(s, q, 1.0, 5), 0.1 * a);
|
||||
}
|
||||
}
|
||||
Grain::Stippled => {
|
||||
// Dots of the colour where it is dark, the background between them.
|
||||
let dots = f64::from(1.0 - luma(c).clamp(0.0, 1.0)) * (0.35 + 0.65 * a);
|
||||
c = if chance < dots { c.map(|v| v * 0.6) } else { [1.0; 3] };
|
||||
}
|
||||
}
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Add noise `n(channel)` (-1..1) times `amount` to colour `c`.
|
||||
fn add(c: &mut [f32; 3], n: impl Fn(u64) -> f64, amount: f64) {
|
||||
for (ch, v) in c.iter_mut().enumerate() {
|
||||
*v += (amount * n(ch as u64)) as f32;
|
||||
}
|
||||
}
|
||||
|
||||
/// A random point of cell (`i`, `j`) of pattern `salt` within `jitter` (0..1, a share of the
|
||||
/// cell) of its centre, in cells.
|
||||
fn jittered(salt: u64, i: i64, j: i64, jitter: f64) -> Point {
|
||||
Point::new(i as f64 + 0.5 + jitter * (rand(salt, i, j, 0) - 0.5), j as f64 + 0.5 + jitter * (rand(salt, i, j, 1) - 0.5))
|
||||
}
|
||||
|
||||
/// Mosaic Tiles: the object laid in irregular tiles about `tile` across, bevelled at their edges,
|
||||
/// with sunken grout between them half a point wide per step of `grout` (1..15), as light as
|
||||
/// `lighten` (0..10) says.
|
||||
pub(super) fn mosaic_tiles(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, tile: f64, grout: f64, lighten: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(tile.is_finite() && tile > 0.0 && grout.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let half = (0.25 * grout).clamp(0.0, 0.225 * tile);
|
||||
let rise = (1.2 * half).max(0.08 * tile);
|
||||
let lighten = (0.08 * lighten.clamp(0.0, 10.0)) as f32;
|
||||
let toward = Light::TopLeft.toward();
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / tile, q.y / tile);
|
||||
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| jittered(SALT_TILES, i, j, 0.5)) else { return };
|
||||
let (e, away) = edge(u, v, near, next);
|
||||
let e = e * tile;
|
||||
let c = straight(*p);
|
||||
let face = lit(c, 0.35 * facing(away * bevel(e - half, rise), toward));
|
||||
// Sunk in shade, then lightened.
|
||||
let sunk = c.map(|v| 0.5 * v + (1.0 - 0.5 * v) * lighten);
|
||||
let g = cover(half - e, place.px) as f32;
|
||||
let mut out = face;
|
||||
for (o, s) in out.iter_mut().zip(sunk) {
|
||||
*o = *o * (1.0 - g) + s * g;
|
||||
}
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Patchwork: the object in squares `square` points across, each filled with the colour around
|
||||
/// its centre and raised to a height of its own (brighter squares stand higher, each a little
|
||||
/// more or less at random), its bevelled sides lit from the top left as strongly as `relief`
|
||||
/// (0..25) says.
|
||||
pub(super) fn patchwork(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, square: f64, relief: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let side = square.max(1.0);
|
||||
if !side.is_finite() {
|
||||
return;
|
||||
}
|
||||
let soft = Soft::new(px, w, side / 3.0 / place.px);
|
||||
let relief = (relief / 25.0).clamp(0.0, 1.0);
|
||||
let rise = 0.25 * side;
|
||||
let toward = Light::TopLeft.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
|
||||
let (x0, y0) = (i as f64 * side, j as f64 * side);
|
||||
let [r, g, b, alpha] = soft.at(place.index(Point::new(x0 + 0.5 * side, y0 + 0.5 * side)));
|
||||
if alpha <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let c = [r, g, b].map(|v| v / alpha);
|
||||
let height = relief * (0.35 + 0.65 * (0.5 * rand(SALT_PATCH, i, j, 0) + 0.5 * f64::from(luma(c))));
|
||||
// The bevels rise inwards from each side.
|
||||
let (fx, fy) = (q.x - x0, q.y - y0);
|
||||
let ramp = |d: f64| cover(rise - d, place.px);
|
||||
let slope = Vec2::new(ramp(fx) - ramp(side - fx), ramp(fy) - ramp(side - fy)) * (1.5 * height);
|
||||
*p = premultiply(lit(c, 0.9 * facing(slope, toward)), alpha);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stained Glass: the object as panes of single colours around random points about `cell_size`
|
||||
/// apart, leaded in the foreground colour (black) half a point wide per step of `border` (1..20),
|
||||
/// lit from behind at its centre as strongly as `light` (0..10) says, fading out towards its
|
||||
/// corners.
|
||||
pub(super) fn stained_glass(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64, border: f64, light: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(cell_size.is_finite() && cell_size > 0.0 && border.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
|
||||
let half = (0.25 * border).clamp(0.0, 0.25 * cell_size);
|
||||
let light = (light / 10.0).clamp(0.0, 1.0);
|
||||
let extent = space.extent;
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / cell_size, q.y / cell_size);
|
||||
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_GLASS, i, j)) else { return };
|
||||
let pane = |at: Point| soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
|
||||
let (glass, other) = (pane(near), pane(next));
|
||||
let (e, _) = edge(u, v, near, next);
|
||||
let lead = cover(half - e * cell_size, place.px) as f32;
|
||||
let glow = if extent.is_finite() && extent > 0.0 { light * (1.0 - (q.to_vec2().hypot() / extent).powi(2)).max(0.0) } else { 0.0 };
|
||||
let [r, g, b, a] = glass;
|
||||
let c = if a > 0.0 { lit([r, g, b].map(|v| v / a), 0.6 * glow) } else { [0.0; 3] };
|
||||
// The lead holds the panes on both sides of it, as opaque as the more opaque one.
|
||||
let alpha = a * (1.0 - lead) + a.max(other[3]) * lead;
|
||||
let rgb = c.map(|v| v * a * (1.0 - lead));
|
||||
*p = if alpha > 0.0 { premultiply(rgb.map(|v| v / alpha), alpha) } else { [0; 4] };
|
||||
});
|
||||
}
|
||||
|
||||
/// Brick in stretcher bond (bricks 20 × 8 points, 1.5-point mortar): each brick raised with
|
||||
/// 1-point bevels and a little higher or lower than the next, rough faced, the mortar sandy.
|
||||
fn brick(p: Point) -> f64 {
|
||||
const LENGTH: f64 = 20.0;
|
||||
const COURSE: f64 = 8.0;
|
||||
const MORTAR: f64 = 1.5;
|
||||
let Some(row) = cell(p.y / COURSE) else { return 0.0 };
|
||||
let x = p.x + if row.rem_euclid(2) == 1 { 0.5 * LENGTH } else { 0.0 };
|
||||
let Some(col) = cell(x / LENGTH) else { return 0.0 };
|
||||
let (fx, fy) = (x - col as f64 * LENGTH, p.y - row as f64 * COURSE);
|
||||
let edge = fx.min(LENGTH - fx).min(fy).min(COURSE - fy) - 0.5 * MORTAR;
|
||||
if edge <= 0.0 {
|
||||
return 0.2 * value_noise(SALT_SURFACE, p.x / 0.6, p.y / 0.6);
|
||||
}
|
||||
edge.min(1.0) + 0.4 * rand(SALT_SURFACE, col, row, 2) + 0.3 * value_noise(SALT_SURFACE + 1, p.x / 1.5, p.y / 1.5)
|
||||
}
|
||||
|
||||
/// A plain weave of threads `period` points apart, each passing over one thread and under the
|
||||
/// next; `coarse` threads (burlap) wander and are hairy, fine ones (canvas) are even.
|
||||
fn weave(p: Point, period: f64, coarse: bool) -> f64 {
|
||||
let (wander, hair) = if coarse { (0.35, 0.35) } else { (0.08, 0.12) };
|
||||
let drift = |salt: u64, t: f64| wander * (2.0 * value_noise(salt, t / (4.0 * period), 0.5) - 1.0);
|
||||
let x = p.x / period + drift(SALT_SURFACE + 3, p.y);
|
||||
let y = p.y / period + drift(SALT_SURFACE + 4, p.x);
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.0 };
|
||||
let (fx, fy) = (x - i as f64, y - j as f64);
|
||||
// Each thread is round across and rises and falls along its length, over one crossing thread
|
||||
// and under the next; the higher of the two threads shows.
|
||||
let profile = |t: f64| (PI * t).sin().max(0.0).sqrt();
|
||||
let over = if i.wrapping_add(j).rem_euclid(2) == 0 { 0.5 } else { -0.5 };
|
||||
let down = profile(fx) * (0.5 + over * (PI * fy).sin());
|
||||
let across = profile(fy) * (0.5 - over * (PI * fx).sin());
|
||||
let thread = down.max(across);
|
||||
let fibres = hair * value_noise(SALT_SURFACE + 5, p.x / (0.25 * period), p.y / (0.25 * period));
|
||||
(thread + fibres) * period / PI
|
||||
}
|
||||
|
||||
/// Sandstone: four octaves of smooth noise (8 down to 1 point) over a fine sandy grain.
|
||||
fn sandstone(p: Point) -> f64 {
|
||||
let (mut h, mut size, mut amp) = (0.0, 8.0, 1.6);
|
||||
for octave in 0..4 {
|
||||
h += amp * value_noise(SALT_SURFACE + 8 + octave, p.x / size, p.y / size);
|
||||
size *= 0.5;
|
||||
amp *= 0.5;
|
||||
}
|
||||
h + 0.06 * cell_noise(SALT_SURFACE + 12, p, 0.5, 0)
|
||||
}
|
||||
|
||||
/// Texturizer: the object on 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.
|
||||
#[allow(clippy::too_many_arguments)] // the effect's five options
|
||||
pub(super) fn texturizer(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
texture: Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
invert: bool,
|
||||
) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(scaling.is_finite() && scaling > 0.0) {
|
||||
return;
|
||||
}
|
||||
// Slopes over a quarter point of the texture, or half a pixel where pixels are larger.
|
||||
let step = (0.25f64).max(0.5 * place.px / scaling);
|
||||
let strength = 0.1 * relief.clamp(0.0, 50.0) * if invert { -1.0 } else { 1.0 };
|
||||
let toward = light.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||
if !(t.x.is_finite() && t.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||
let (left, right, up, down) = (at(-step, 0.0), at(step, 0.0), at(0.0, -step), at(0.0, step));
|
||||
let slope = Vec2::new(right - left, down - up) / (2.0 * step);
|
||||
// Ridges catch some light from any side and hollows stay in shade, so the surface shows
|
||||
// whichever way it runs.
|
||||
let ridge = (at(0.0, 0.0) - 0.25 * (left + right + up + down)) / step;
|
||||
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nearest_tests {
|
||||
use super::*;
|
||||
|
||||
/// Compare with all 25 candidates, without the production distance-pruning shortcut.
|
||||
fn brute_force(u: f64, v: f64, point: &impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
|
||||
let (i, j) = (cell(u)?, cell(v)?);
|
||||
let mut candidates: Vec<_> = AROUND
|
||||
.iter()
|
||||
.map(|(di, dj)| {
|
||||
let p = point(i.wrapping_add(*di), j.wrapping_add(*dj));
|
||||
((p.x - u).hypot(p.y - v), p)
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some((candidates.first()?.1, candidates.get(1)?.1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_points_match_all_candidates_at_cell_and_rounding_boundaries() {
|
||||
let mut queries = vec![];
|
||||
for y in -20..=20 {
|
||||
for x in -24..=24 {
|
||||
queries.push((f64::from(x) / 13.0, f64::from(y) / 11.0));
|
||||
}
|
||||
}
|
||||
queries.extend([
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
(1.0 - f64::EPSILON, -1.0),
|
||||
(1.0 + f64::EPSILON, -1.0 - f64::EPSILON),
|
||||
(4.0e6, -4.0e6),
|
||||
(1.0e14 + 0.25, -1.0e14 + 0.5),
|
||||
(f64::INFINITY, 0.0),
|
||||
(f64::NAN, 0.0),
|
||||
]);
|
||||
for pattern in 0..3 {
|
||||
let point = |i: i64, j: i64| match pattern {
|
||||
0 => point_in(SALT_CRACKS, i, j),
|
||||
1 => Point::new(i as f64 + 0.5, j as f64 + 0.5),
|
||||
_ => Point::new(i as f64, j as f64),
|
||||
};
|
||||
let mut around = Around::new();
|
||||
for &(u, v) in &queries {
|
||||
assert_eq!(nearest_two(u, v, &mut around, point), brute_force(u, v, &point), "pattern {pattern} at ({u}, {v})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
art.isolate = false;
|
||||
// One element: its pieces never knock each other out.
|
||||
art.knockout = vectorcraft_doc::Knockout::Off;
|
||||
art.mask = None;
|
||||
art.extras_mut().mask = None;
|
||||
art.appearance.effects.clear();
|
||||
Some(art)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
|
||||
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 })
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
@@ -159,7 +159,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
out.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.mask = n.mask.clone();
|
||||
out.extras_mut().mask = n.extras().mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
|
||||
|
||||
pub const REVOLVE: &str = "threeD.revolve";
|
||||
|
||||
fn visible(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible && e.id == REVOLVE)
|
||||
}
|
||||
|
||||
pub fn has_revolve(n: &Node) -> bool {
|
||||
visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects()))
|
||||
}
|
||||
|
||||
/// Shared document-parameter conversion for rendering and interactive preview guides.
|
||||
pub fn revolve_options(p: &Value) -> Revolve {
|
||||
Revolve {
|
||||
angle: num(p, "angle", 360.0),
|
||||
offset: num(p, "offset", 0.0),
|
||||
edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left },
|
||||
rotation_x: num(p, "rotationX", 0.0),
|
||||
rotation_y: num(p, "rotationY", 0.0),
|
||||
rotation_z: num(p, "rotationZ", 0.0),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
return Err("Revolve requires at least two anchors".into());
|
||||
}
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
fn profile(n: &Node) -> Option<PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn surface(
|
||||
n: &Node,
|
||||
path: &PathData,
|
||||
fx: &Effect,
|
||||
paint: Paint,
|
||||
opacity: f32,
|
||||
blend: vectorcraft_doc::color::BlendMode,
|
||||
expand: bool,
|
||||
) -> Option<Node> {
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
let visible = (expand
|
||||
&& flag(&fx.params, "expandVisibleOnly", true)
|
||||
&& paint.color().is_some()
|
||||
&& opacity == 1.0
|
||||
&& blend == Default::default()
|
||||
&& n.has_default_transparency())
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
// Matching narrow edge paint overlaps their coverage in the canvas and exporters.
|
||||
// Keep opacity on the surface group so these edges never accumulate transparency.
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs
|
||||
/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha.
|
||||
pub fn revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, false)
|
||||
}
|
||||
|
||||
/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged.
|
||||
pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, true)
|
||||
}
|
||||
|
||||
fn art(n: &Node, expand: bool) -> Option<Node> {
|
||||
if !has_revolve(n) {
|
||||
return None;
|
||||
}
|
||||
let path = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let mut children = vec![];
|
||||
let mut remaining = n.appearance.effects.clone();
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let fx = n.appearance.effects.get(index)?;
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
// An object's surface uses its top visible fill, falling back to its stroke.
|
||||
let item = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
|
||||
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?));
|
||||
// Later geometry acts on the projected art as a whole through container evaluation.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining.retain(|e| !is_geometry(&e.id));
|
||||
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let fx = item.effects().get(index)?;
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?;
|
||||
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
|
||||
children.push(Arc::new(art));
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
fn profile_node() -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 40.0));
|
||||
b.line_to((130.0, 60.0));
|
||||
b.line_to((130.0, 140.0));
|
||||
b.line_to((100.0, 160.0));
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))
|
||||
}
|
||||
fn fx() -> Effect {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx());
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert!(!a.children().unwrap().is_empty());
|
||||
assert_eq!(n.path_data().unwrap(), &before);
|
||||
let b = a.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((80.0, 20.0)), false);
|
||||
let moved = revolve_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6);
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(revolve_art(&n).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn item_effect_and_transparency_are_scoped_to_whole_surface() {
|
||||
let mut n = profile_node();
|
||||
n.opacity = 0.6;
|
||||
n.appearance.items[0].effects_mut().push(fx());
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert_eq!(a.opacity, 0.6);
|
||||
let surface = &a.children().unwrap()[0];
|
||||
assert_eq!(surface.opacity, 0.4);
|
||||
assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c)));
|
||||
}
|
||||
#[test]
|
||||
fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let mut n = profile_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id));
|
||||
assert!(!has_revolve(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
let source = n.path_data().unwrap().clone();
|
||||
let live = revolve_art(&n).unwrap();
|
||||
let expanded = expand_revolve_art(&n).unwrap();
|
||||
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
|
||||
assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface");
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
|
||||
assert_eq!(expand_revolve_art(&n), Some(live));
|
||||
assert_eq!(n.path_data(), Some(&source));
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(true);
|
||||
assert_eq!(expand_revolve_art(&n), Some(expanded.clone()));
|
||||
// Per-item Revolve uses the same option, without trimming another item's artwork.
|
||||
let effect = n.appearance.effects.remove(0);
|
||||
n.appearance.items[0].effects_mut().push(effect);
|
||||
assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
for change in 0..4 {
|
||||
let mut sample = n.clone();
|
||||
match change {
|
||||
0 => sample.opacity = 0.5,
|
||||
1 => {
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
}
|
||||
2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply,
|
||||
_ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()),
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}");
|
||||
}
|
||||
let mut sample = n;
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY };
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
|
||||
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
/// The 3D effects this module draws (Revolve has its own).
|
||||
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
|
||||
|
||||
/// Is `id` one of the 3D and Materials effects (Revolve included)?
|
||||
pub fn is_three_d(id: &str) -> bool {
|
||||
id == crate::REVOLVE || SOLIDS.contains(&id)
|
||||
}
|
||||
|
||||
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
|
||||
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
|
||||
pub fn has_solid(n: &Node) -> bool {
|
||||
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
|
||||
}
|
||||
|
||||
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
|
||||
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
|
||||
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
|
||||
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
|
||||
/// geometry of each view.
|
||||
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
|
||||
("front", [0.0, 0.0, 0.0]),
|
||||
("back", [0.0, 180.0, 0.0]),
|
||||
("left", [0.0, 90.0, 0.0]),
|
||||
("right", [0.0, -90.0, 0.0]),
|
||||
("top", [-90.0, 0.0, 0.0]),
|
||||
("bottom", [90.0, 0.0, 0.0]),
|
||||
("offAxisFront", [-21.88, -23.4, 9.06]),
|
||||
("offAxisBack", [-158.12, 23.4, -170.94]),
|
||||
("offAxisLeft", [-40.74, 58.39, -36.26]),
|
||||
("offAxisRight", [-139.26, -58.39, 143.74]),
|
||||
("offAxisTop", [-111.88, -23.4, 9.06]),
|
||||
("offAxisBottom", [68.12, -23.4, 9.06]),
|
||||
("isometricLeft", [-45.0, -35.26, 30.0]),
|
||||
("isometricRight", [-45.0, 35.26, -30.0]),
|
||||
("isometricTop", [45.0, -35.26, 30.0]),
|
||||
("isometricBottom", [-45.0, -35.26, -30.0]),
|
||||
];
|
||||
|
||||
/// The view parameters every 3D effect shares, named as Revolve's.
|
||||
pub fn view_options(p: &Value) -> View {
|
||||
View {
|
||||
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
|
||||
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
|
||||
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
|
||||
pub fn extrude_options(p: &Value) -> Extrude {
|
||||
Extrude {
|
||||
depth: num(p, "depth", 50.0),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
|
||||
shape,
|
||||
width: num(p, "bevelWidth", 4.0),
|
||||
height: num(p, "bevelHeight", 4.0),
|
||||
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
|
||||
space: num(p, "bevelSpace", 0.0) / 100.0,
|
||||
inside: flag(p, "bevelInside", false),
|
||||
both_sides: flag(p, "bevelBothSides", false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate {
|
||||
depth: num(p, "depth", 0.0),
|
||||
volume: num(p, "volume", 50.0),
|
||||
both_sides: flag(p, "bothSides", false),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, _) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
|
||||
{
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
facets(id, &path, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// 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) -> Node {
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
group
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
||||
_ => path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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);
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
}
|
||||
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, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
|
||||
/// the object's id and transparency, the source path untouched. `None` without such an effect.
|
||||
pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
if !has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
children.extend(art_for(n, fx, &path, rule, &painted)?);
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
for item in &painted {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
for mut art in art_for(n, fx, &p, rule, &[*item])? {
|
||||
Arc::make_mut(&mut art).appearance.effects =
|
||||
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
children.push(art);
|
||||
}
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_solids.rs"]
|
||||
mod tests;
|
||||
@@ -19,7 +19,7 @@ mod width;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, PathEl, PathSeg};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
|
||||
|
||||
@@ -116,10 +116,58 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
(None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
|
||||
(None, None) => plain_outline(line, &style(st, width), tol),
|
||||
}
|
||||
}
|
||||
|
||||
/// `line` stroked with `style`, to fill with the non-zero rule.
|
||||
///
|
||||
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
|
||||
/// than the shape can hold (its inradius), the inner one has crossed the middle and comes out
|
||||
/// the far side: wound against the outer contour it would cut a hole where the pen in fact
|
||||
/// overlaps itself (#1396). Such a contour is left out, so the stroke paints solid.
|
||||
// shortcut: closed subpaths of plain strokes only. An open path that turns back on itself inside
|
||||
// a wider stroke (and dashes on such a shape) can still cancel; fix it in the offsetting if reported.
|
||||
fn plain_outline(line: &BezPath, style: &kurbo::Stroke, tol: f64) -> BezPath {
|
||||
let stroke = |els: &[PathEl]| kurbo::stroke(els.iter().copied(), style, &kurbo::StrokeOpts::default(), tol);
|
||||
let els = line.elements();
|
||||
// Only a stroke at least as wide as a closed subpath's bounds can cross over: everything
|
||||
// else takes the one call it always did.
|
||||
let may_cross = |sub: &[PathEl]| {
|
||||
let b = BezPath::from_vec(sub.to_vec()).bounding_box();
|
||||
style.width >= b.width().min(b.height())
|
||||
};
|
||||
let subs = subpaths(line);
|
||||
if !subs.iter().any(|(r, closed)| *closed && els.get(r.clone()).is_some_and(may_cross)) {
|
||||
return stroke(els);
|
||||
}
|
||||
let reach = style.width / 2.0 - 2.0 * tol;
|
||||
let mut out = BezPath::new();
|
||||
for (r, closed) in subs {
|
||||
let Some(sub) = els.get(r) else { continue };
|
||||
let outline = stroke(sub);
|
||||
if !(closed && may_cross(sub)) {
|
||||
out.extend(outline.iter());
|
||||
continue;
|
||||
}
|
||||
let sub = BezPath::from_vec(sub.to_vec());
|
||||
for (c, _) in subpaths(&outline) {
|
||||
let Some(contour) = outline.elements().get(c) else { continue };
|
||||
if reaches_edge(contour, &sub, reach) {
|
||||
out.extend(contour.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether some point of `contour` lies at least `reach` from `path`: an offset contour that
|
||||
/// never does is nowhere the edge of the stroke.
|
||||
fn reaches_edge(contour: &[PathEl], path: &BezPath, reach: f64) -> bool {
|
||||
let far = |p: kurbo::Point| path.segments().all(|s| s.nearest(p, 1e-6).distance_sq >= reach * reach);
|
||||
kurbo::segments(contour.iter().copied()).any(|s| far(s.eval(0.0)) || far(s.eval(0.5)))
|
||||
}
|
||||
|
||||
/// Append `seg` to `out` (which must already have a current point at its start).
|
||||
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
|
||||
match seg {
|
||||
|
||||
@@ -37,6 +37,23 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
/// #1396: a stroke wider than the shape still covers the interior and reaches half the weight past it.
|
||||
/// A thinner stroke keeps the hole in the middle.
|
||||
#[test]
|
||||
fn a_stroke_wider_than_the_shape_covers_the_interior() {
|
||||
let circle = kurbo::Circle::new(Point::new(0.0, 0.0), 5.0).to_path(0.1);
|
||||
let st = stroke(40.0, |_| {});
|
||||
let outline = line_outline(&circle, &st, 40.0, 0.25);
|
||||
let b = outline.bounding_box();
|
||||
assert!(b.x0 < -24.0 && b.x1 > 24.0, "half the weight past the radius: {b:?}");
|
||||
assert!(kurbo::Shape::contains(&outline, Point::ZERO), "the middle of a fat stroke is painted");
|
||||
let thin = line_outline(&circle, &stroke(4.0, |_| {}), 4.0, 0.25);
|
||||
assert!(!kurbo::Shape::contains(&thin, Point::ZERO), "a narrow stroke stays a ring");
|
||||
assert!(kurbo::Shape::contains(&thin, Point::new(5.0, 0.0)));
|
||||
let square = line_outline(&Rect::new(0.0, 0.0, 8.0, 8.0).to_path(0.1), &st, 40.0, 0.25);
|
||||
assert!(kurbo::Shape::contains(&square, Point::new(4.0, 4.0)), "a fat stroke on a small rectangle covers it");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dashes and dots
|
||||
|
||||
#[test]
|
||||
@@ -304,3 +321,85 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
|
||||
}
|
||||
assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid");
|
||||
}
|
||||
|
||||
/// Grid points within `reach` of the inside of `path` (not past an open end) that `outline`
|
||||
/// leaves unpainted.
|
||||
fn unpainted_near(path: &BezPath, outline: &BezPath, reach: f64) -> Vec<Point> {
|
||||
let b = path.bounding_box().inflate(reach, reach);
|
||||
let ends: Vec<Point> = if is_closed(path) { vec![] } else { path.segments().flat_map(|s| [s.start(), s.end()]).collect() };
|
||||
let ends = [ends.first().copied(), ends.last().copied()];
|
||||
let mut missed = Vec::new();
|
||||
for i in 0..=60 {
|
||||
for j in 0..=60 {
|
||||
let p = Point::new(b.x0 + b.width() * f64::from(i) / 60.0, b.y0 + b.height() * f64::from(j) / 60.0);
|
||||
let nearest = path
|
||||
.segments()
|
||||
.map(|s| (s, kurbo::ParamCurveNearest::nearest(&s, p, 1e-6)))
|
||||
.min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq))
|
||||
.map(|(s, n)| (s.eval(n.t), n.distance_sq.sqrt()));
|
||||
let Some((at, dist)) = nearest else { continue };
|
||||
let past_end = ends.iter().flatten().any(|e| e.distance(at) < 1e-6);
|
||||
if dist <= reach && !past_end && outline.winding(p) == 0 {
|
||||
missed.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
missed
|
||||
}
|
||||
|
||||
/// #1396: whatever the weight is next to the size of the shape, every point within half the
|
||||
/// weight of the path is painted (the stroke is the path swept by a pen that wide).
|
||||
#[test]
|
||||
fn a_fat_stroke_paints_everything_within_half_its_weight() {
|
||||
let mut shapes: Vec<(String, BezPath)> = Vec::new();
|
||||
for r in [3.0, 6.0, 10.0, 15.0, 20.0, 24.0, 40.0] {
|
||||
let circle = kurbo::Circle::new(Point::ZERO, r).to_path(0.01);
|
||||
let mut ellipse = kurbo::Ellipse::new(Point::ZERO, (r, r / 2.0), 0.3).to_path(0.01);
|
||||
ellipse.close_path();
|
||||
let arc = BezPath::from_vec(circle.elements().iter().take(3).copied().collect());
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
for p in [(r * 2.0, 0.0), (r * 2.0, r), (r, r), (r, r * 2.0), (0.0, r * 2.0)] {
|
||||
l.line_to(p);
|
||||
}
|
||||
l.close_path();
|
||||
let mut two = circle.clone();
|
||||
two.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 60.0).to_path(0.01).iter());
|
||||
shapes.extend([
|
||||
(format!("circle r={r}"), circle),
|
||||
(format!("ellipse {r}"), ellipse),
|
||||
(format!("rect {r}"), Rect::new(0.0, 0.0, r, r * 1.5).to_path(0.1)),
|
||||
(format!("open arc r={r}"), arc),
|
||||
(format!("L {r}"), l),
|
||||
(format!("circle r={r} beside a large one"), two),
|
||||
]);
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for (name, path) in &shapes {
|
||||
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
|
||||
let outline = line_outline(path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
|
||||
// A bevel stops short of the pen's reach at a corner.
|
||||
let reach = if join == LineJoin::Bevel { 16.0 } else { 24.5 };
|
||||
let missed = unpainted_near(path, &outline, reach);
|
||||
if !missed.is_empty() {
|
||||
failures.push(format!("{name} {join:?}: {} unpainted, e.g. {:?} ({} contours)", missed.len(), missed.first(), moves(&outline)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stroke the shape has room for keeps its hole, also beside a subpath that has none.
|
||||
#[test]
|
||||
fn a_stroke_narrower_than_the_shape_keeps_its_hole() {
|
||||
let mut path = kurbo::Circle::new(Point::ZERO, 30.0).to_path(0.01);
|
||||
path.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 10.0).to_path(0.01).iter());
|
||||
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
|
||||
let outline = line_outline(&path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
|
||||
assert_eq!(outline.winding(Point::ZERO), 0, "{join:?}: the hole of the wide ring");
|
||||
assert_eq!(outline.winding(Point::new(4.0, 0.0)), 0, "{join:?}: up to its inner edge");
|
||||
assert_ne!(outline.winding(Point::new(6.0, 0.0)), 0, "{join:?}");
|
||||
assert_ne!(outline.winding(Point::new(200.0, 0.0)), 0, "{join:?}: the small circle is solid");
|
||||
assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob");
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() {
|
||||
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
// #1554: on a straight line every ridge is the same distance from the line and as far from
|
||||
// the next one, whether the points are corners or smooth.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
|
||||
for points in ["corner", "smooth"] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
|
||||
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(ps.len(), 6, "{points}");
|
||||
for (k, q) in ps.iter().enumerate() {
|
||||
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
|
||||
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
fn zigzag_on_a_circle_is_regular() {
|
||||
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
|
||||
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts.len(), 16);
|
||||
let start = pts[0].y.atan2(pts[0].x);
|
||||
let mut radii = vec![];
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
|
||||
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
|
||||
radii.push(q.to_vec2().hypot().round());
|
||||
}
|
||||
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
|
||||
}
|
||||
|
||||
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let c = curve.subpaths[0].segment(0);
|
||||
let len = c.arclen(1e-6);
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
|
||||
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
|
||||
assert_eq!(along.len(), 5);
|
||||
for (k, share) in along.iter().enumerate() {
|
||||
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_grows_and_shrinks() {
|
||||
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
|
||||
|
||||
Loaded 100 of 647 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user