Compare commits
658
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 |
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
|
||||
@@ -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,7 +371,7 @@ 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/*
|
||||
@@ -390,18 +390,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: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
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@v7
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
@@ -472,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:
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
Generated
+95
-28
@@ -806,6 +806,14 @@ 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"
|
||||
@@ -1860,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"
|
||||
@@ -1914,6 +1943,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1921,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"
|
||||
@@ -3152,6 +3206,8 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4133,6 +4189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4433,11 +4490,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
@@ -4448,12 +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",
|
||||
@@ -4463,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4474,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4486,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4502,7 +4563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4519,11 +4580,12 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"windows",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4535,7 +4597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4552,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4567,7 +4629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4602,12 +4664,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -4622,7 +4685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4636,7 +4699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4648,7 +4711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4667,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4681,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4693,7 +4756,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4722,7 +4785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4736,7 +4799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4761,7 +4824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4785,7 +4848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4802,9 +4865,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
@@ -4812,6 +4876,7 @@ dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"windows-registry",
|
||||
@@ -4820,14 +4885,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4842,7 +4908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4850,12 +4916,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4888,7 +4953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5998,7 +6063,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6182,6 +6247,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6190,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",
|
||||
|
||||
+2
-1
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.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" }
|
||||
|
||||
@@ -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
|
||||
@@ -216,7 +220,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
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.
|
||||
@@ -225,7 +229,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- 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;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery;
|
||||
- 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.
|
||||
|
||||
+22
-11
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **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)
|
||||
|
||||
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
|
||||
@@ -26,7 +26,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 28/57, 3D 1/4 (initial Revolve) |
|
||||
| **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 |
|
||||
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| 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 (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
|
||||
| **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 |
|
||||
|
||||
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| 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) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-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) |
|
||||
@@ -73,8 +73,8 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 48% | 24–40 |
|
||||
| 3D and Materials | ~8% | 50–85 |
|
||||
| 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 |
|
||||
@@ -106,10 +106,10 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | none | 0% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Italian, Russian and Ukrainian (full), Czech (menus only).
|
||||
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).
|
||||
|
||||
## Upcoming
|
||||
@@ -120,14 +120,25 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: 29 filters and the Effect Gallery, 24–40 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).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-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 |
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +55,7 @@ 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", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
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", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
@@ -63,6 +67,15 @@ raw-window-handle = "0.6"
|
||||
# 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-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
|
||||
@@ -93,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
|
||||
@@ -102,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 {
|
||||
@@ -130,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);
|
||||
@@ -266,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.
|
||||
@@ -285,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),
|
||||
@@ -400,15 +432,21 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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| {
|
||||
@@ -417,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 {
|
||||
@@ -433,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:?}"),
|
||||
@@ -513,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()
|
||||
}
|
||||
@@ -555,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
|
||||
@@ -571,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
|
||||
@@ -583,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
|
||||
@@ -593,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,
|
||||
@@ -605,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.
|
||||
@@ -624,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
|
||||
@@ -645,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]
|
||||
@@ -741,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)");
|
||||
@@ -754,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),
|
||||
[
|
||||
@@ -800,10 +881,10 @@ 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"]);
|
||||
}
|
||||
@@ -831,7 +912,7 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
@@ -852,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
|
||||
@@ -895,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.
|
||||
@@ -912,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
-57
@@ -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,6 +35,8 @@ 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;
|
||||
@@ -39,6 +45,7 @@ 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;
|
||||
@@ -80,8 +87,9 @@ impl eframe::App for App {
|
||||
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.take_ime_discard() {
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
@@ -114,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" {
|
||||
@@ -255,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}"))
|
||||
@@ -332,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
|
||||
@@ -462,7 +604,46 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -472,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
|
||||
@@ -538,6 +697,9 @@ 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")]
|
||||
let compositor = gpu::preferred_compositor(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
@@ -551,6 +713,25 @@ fn main() -> std::process::ExitCode {
|
||||
let startup = std::sync::Arc::new(gpu::Startup::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 = {
|
||||
let mut options = options;
|
||||
@@ -559,13 +740,28 @@ 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
|
||||
};
|
||||
@@ -584,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() {
|
||||
|
||||
@@ -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,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 |
@@ -25,10 +25,6 @@ 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
|
||||
|
||||
@@ -65,3 +61,35 @@ 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)"]
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -479,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();
|
||||
@@ -487,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.
|
||||
|
||||
@@ -270,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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+157
-2
@@ -80,6 +80,122 @@ impl GraphKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -172,9 +288,9 @@ pub struct GraphSpec {
|
||||
/// 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).
|
||||
/// 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,
|
||||
@@ -200,6 +316,35 @@ pub struct GraphSpec {
|
||||
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")]
|
||||
@@ -302,6 +447,16 @@ impl Default for GraphSpec {
|
||||
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,
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
+332
-39
@@ -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;
|
||||
@@ -57,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;
|
||||
@@ -66,15 +76,18 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
|
||||
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;
|
||||
@@ -82,12 +95,12 @@ 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,
|
||||
};
|
||||
@@ -538,39 +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: Variables,
|
||||
pub variables: Shared<Variables>,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
@@ -589,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")]
|
||||
@@ -658,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 {
|
||||
@@ -668,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 {
|
||||
@@ -684,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,
|
||||
@@ -693,17 +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![],
|
||||
variables: Variables::default(),
|
||||
saved_selections: Default::default(),
|
||||
variables: Default::default(),
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
@@ -729,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))));
|
||||
@@ -758,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);
|
||||
@@ -795,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)?;
|
||||
@@ -887,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)
|
||||
@@ -966,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() {
|
||||
@@ -1149,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
|
||||
@@ -1202,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> {
|
||||
@@ -1271,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();
|
||||
@@ -1391,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]));
|
||||
|
||||
+82
-100
@@ -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
|
||||
@@ -53,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();
|
||||
@@ -60,9 +70,9 @@ 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;
|
||||
@@ -76,6 +86,7 @@ impl Selection {
|
||||
self.slices.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
self.segments.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
self.key = None;
|
||||
}
|
||||
@@ -114,20 +125,78 @@ 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));
|
||||
// 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.
|
||||
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
||||
@@ -166,91 +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());
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
+67
-2
@@ -96,10 +96,51 @@ pub struct CharStyle {
|
||||
#[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). Vertical type sets them as before
|
||||
/// for now.
|
||||
/// 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
|
||||
@@ -212,6 +253,7 @@ impl Default for CharStyle {
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
digits: Digits::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1238,6 +1280,29 @@ 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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
-21
@@ -40,6 +40,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
crate::has_revolve(n)
|
||||
|| crate::has_solid(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
@@ -93,7 +94,7 @@ 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) {
|
||||
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));
|
||||
}
|
||||
@@ -163,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
|
||||
@@ -231,7 +233,7 @@ 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) {
|
||||
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));
|
||||
}
|
||||
@@ -298,7 +300,7 @@ 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) {
|
||||
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 { .. }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +43,7 @@ pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
@@ -70,6 +71,10 @@ 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.
|
||||
@@ -125,6 +130,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"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"]),
|
||||
@@ -154,6 +161,7 @@ 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"];
|
||||
@@ -195,6 +203,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{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…",
|
||||
@@ -298,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…",
|
||||
@@ -661,7 +697,7 @@ pub fn is_geometry(id: &str) -> bool {
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& id != REVOLVE
|
||||
&& !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);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! 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
|
||||
@@ -12,6 +12,7 @@
|
||||
//! 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;
|
||||
@@ -32,7 +33,8 @@ 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; 27] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"artistic.filmGrain",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -66,6 +68,9 @@ pub const PIXEL_EFFECTS: [&str; 27] = [
|
||||
/// 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 },
|
||||
@@ -192,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"),
|
||||
@@ -338,7 +348,8 @@ impl PixelFx {
|
||||
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::FilmGrain { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
| PixelFx::Grain { .. }
|
||||
@@ -378,7 +389,7 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::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.
|
||||
@@ -399,6 +410,7 @@ 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),
|
||||
|
||||
@@ -45,6 +45,418 @@ fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() {
|
||||
assert_eq!(fx("artistic.filmGrain", json!({})), PixelFx::FilmGrain { grain: 4.0, highlight: 0.0, intensity: 10.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.filmGrain", json!({"grain": 1e308, "highlightArea": -100, "intensity": 200})),
|
||||
PixelFx::FilmGrain { grain: 20.0, highlight: 0.0, intensity: 10.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.filmGrain", json!({"grain": "1e999", "intensity": []})), fx("artistic.filmGrain", json!({})));
|
||||
let (w, h) = (24, 18);
|
||||
let src = image(w, h, |x, y| [(3 * x) as u8, (2 * y) as u8, 12, 128]);
|
||||
let mut out = src.clone();
|
||||
let neutral = fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": 0, "intensity": 0}));
|
||||
neutral.apply(&mut out, w, h, &space(w, h));
|
||||
assert_eq!(out, src);
|
||||
assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0);
|
||||
assert_eq!(neutral.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_is_monochrome_and_quieter_in_highlights() {
|
||||
let (w, h) = (64, 48);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20, "intensity": 0}));
|
||||
let mut changes = Vec::new();
|
||||
for tone in [64, 128, 240] {
|
||||
let mut out = image(w, h, |_, _| grey(tone));
|
||||
grain.apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
|
||||
changes.push(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(tone))).sum::<f64>() / (w * h) as f64);
|
||||
}
|
||||
assert!(changes[0] > 3.0 * changes[2] && changes[1] > 3.0 * changes[2], "shadows and midtones take more grain: {changes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_highlight_area_and_intensity_change_the_tones() {
|
||||
let (w, h) = (16, 16);
|
||||
let apply = |tone, area, intensity| {
|
||||
let mut out = image(w, h, |_, _| grey(tone));
|
||||
fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": area, "intensity": intensity})).apply(&mut out, w, h, &space(w, h));
|
||||
out[0]
|
||||
};
|
||||
assert_eq!(apply(64, 0, 10), 64, "intensity alone doesn't recolour shadows");
|
||||
assert_eq!(apply(192, 0, 10), 192, "intensity alone doesn't add global contrast");
|
||||
assert!(apply(192, 20, 10) > apply(192, 20, 0), "intensity sets highlight strength");
|
||||
let tone = 192;
|
||||
let mut strong = image(w, h, |_, _| grey(tone));
|
||||
let mut quiet = strong.clone();
|
||||
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 0})).apply(&mut strong, w, h, &space(w, h));
|
||||
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 10})).apply(&mut quiet, w, h, &space(w, h));
|
||||
let spread = |data: &[u8]| data.as_chunks::<4>().0.iter().map(|p| u32::from(p[0].abs_diff(tone))).sum::<u32>();
|
||||
assert!(spread(&strong) > spread(&quiet), "Intensity also quiets highlight grain with the default Highlight Area");
|
||||
let highlights = [0, 10, 20].map(|area| apply(192, area, 10));
|
||||
assert!(highlights.windows(2).all(|p| p[0] < p[1]), "wider highlight area lifts more midtones: {highlights:?}");
|
||||
assert_eq!(apply(0, 20, 10), 0, "black doesn't become a white background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_preserves_coverage_without_a_halo() {
|
||||
let (w, h) = (40, 30);
|
||||
let src = image(w, h, |x, y| match (x + y) % 5 {
|
||||
0 => [0; 4],
|
||||
1 => [1, 0, 1, 1],
|
||||
2 => [75, 12, 25, 128],
|
||||
_ => [180, 60, 90, 255],
|
||||
});
|
||||
let mut out = src.clone();
|
||||
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_ne!(out, src);
|
||||
for (p, original) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], original[3]);
|
||||
assert!(p[..3].iter().all(|c| *c <= p[3]), "premultiplied: {p:?}");
|
||||
if original[3] == 0 {
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_pattern_survives_redraws_moves_resolution_changes_and_tiles() {
|
||||
let (w, h) = (32, 24);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
|
||||
let mut whole = src.clone();
|
||||
grain.apply(&mut whole, w, h, &space(w, h));
|
||||
let mut again = src.clone();
|
||||
grain.apply(&mut again, w, h, &space(w, h));
|
||||
assert_eq!(whole, again);
|
||||
let moved =
|
||||
PixelSpace { to_doc: Affine::translate((17.0, -9.0)), center: space(w, h).center + vectorcraft_geom::Vec2::new(17.0, -9.0), ..space(w, h) };
|
||||
let mut shifted = src;
|
||||
grain.apply(&mut shifted, w, h, &moved);
|
||||
assert_eq!(whole, shifted);
|
||||
let mut two = image(2 * w, 2 * h, |_, _| grey(128));
|
||||
grain.apply(&mut two, 2 * w, 2 * h, &PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) });
|
||||
let mut difference = 0u64;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let down = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| u32::from(at(&two, 2 * w, 2 * x + dx, 2 * y + dy)[0]));
|
||||
let down = (down.iter().sum::<u32>() / 4) as u8;
|
||||
difference += u64::from(at(&whole, w, x, y)[0].abs_diff(down));
|
||||
}
|
||||
}
|
||||
let mean_delta = difference as f64 / (w * h) as f64;
|
||||
let fine_deviation = film_grain_deviation(two.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
|
||||
// Two diagonal samples approximate the four point samples in the finer raster. Bound
|
||||
// their error relative to grain strength, so a stronger tonal curve does not fail it.
|
||||
assert!(mean_delta < 0.25 * fine_deviation, "the field keeps its document scale: mean delta {mean_delta}, point deviation {fine_deviation}");
|
||||
let (tw, th) = (13, 11);
|
||||
let mut tile = image(tw, th, |_, _| grey(128));
|
||||
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: Affine::translate((5.0, 3.0)), ..space(w, h) });
|
||||
for y in 0..th {
|
||||
for x in 0..tw {
|
||||
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 5, y + 3), "no tile seam");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_keeps_colour_without_an_extra_contrast_curve_and_scales_with_alpha() {
|
||||
let (w, h) = (256, 256);
|
||||
let original = [180, 100, 60, 255];
|
||||
let mut out = image(w, h, |_, _| original);
|
||||
// Intensity with no highlight area must not silently change colour or clip channels.
|
||||
fx("artistic.filmGrain", json!({"grain": 0, "intensity": 10})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| *p == original));
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 4}));
|
||||
grain.apply(&mut out, w, h, &space(w, h));
|
||||
for p in out.as_chunks::<4>().0 {
|
||||
// A neutral floating offset can round either side of a half-byte tie in each channel.
|
||||
assert!((i32::from(p[0]) - i32::from(p[1]) - 80).abs() <= 1, "one neutral offset, no channel clipping");
|
||||
assert!((i32::from(p[1]) - i32::from(p[2]) - 40).abs() <= 1);
|
||||
}
|
||||
// These straight colours have exact premultiplied representations at alpha 85 and 170.
|
||||
let colour = [180, 120, 60];
|
||||
let mut opaque = image(w, h, |_, _| [colour[0], colour[1], colour[2], 255]);
|
||||
grain.apply(&mut opaque, w, h, &space(w, h));
|
||||
for alpha in [85u8, 170] {
|
||||
let mut translucent = image(w, h, |_, _| {
|
||||
let [r, g, b] = colour.map(|c| (u32::from(c) * u32::from(alpha) / 255) as u8);
|
||||
[r, g, b, alpha]
|
||||
});
|
||||
grain.apply(&mut translucent, w, h, &space(w, h));
|
||||
for (p, full) in translucent.as_chunks::<4>().0.iter().zip(opaque.as_chunks::<4>().0) {
|
||||
for c in 0..3 {
|
||||
let expected = (u32::from(full[c]) * u32::from(alpha) + 127) / 255;
|
||||
assert!(i32::from(p[c]).abs_diff(expected as i32) <= 1, "alpha scales colour consistently: {p:?}, {full:?}");
|
||||
}
|
||||
assert_eq!(p[3], alpha);
|
||||
}
|
||||
}
|
||||
let mut white = image(w, h, |_, _| grey(255));
|
||||
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 10})).apply(&mut white, w, h, &space(w, h));
|
||||
assert!(white.as_chunks::<4>().0.iter().all(|p| *p == grey(255)), "white stays clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_is_visible_on_dark_fills_with_even_shadow_and_midtone_strength() {
|
||||
let (w, h) = (128, 96);
|
||||
let grain = fx("artistic.filmGrain", json!({}));
|
||||
let render = |tone| {
|
||||
let mut out = image(w, h, |_, _| grey(tone));
|
||||
grain.apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
let black = render(0);
|
||||
assert!(black.as_chunks::<4>().0.iter().any(|p| p[0] > 2), "pure black takes light grain speckles");
|
||||
assert!(film_grain_deviation(black.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))) > 2.0);
|
||||
let dark = render(26);
|
||||
let mid = render(128);
|
||||
let [dark, mid] = [dark, mid].map(|out| film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
|
||||
assert!(dark > 4.0 && (dark / mid - 1.0).abs() < 0.02, "default grain must remain even across shadows and midtones: {dark}, {mid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_leaves_soft_but_visible_grain_on_light_fills_at_defaults() {
|
||||
let (w, h) = (128, 96);
|
||||
let colour = [245, 240, 225, 255];
|
||||
let mut cream = image(w, h, |_, _| colour);
|
||||
fx("artistic.filmGrain", json!({})).apply(&mut cream, w, h, &space(w, h));
|
||||
let deviation = film_grain_deviation(cream.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
|
||||
let changed = cream.as_chunks::<4>().0.iter().filter(|p| **p != colour).count();
|
||||
assert!((0.5..3.0).contains(&deviation), "soft grain must remain visible on cream: {deviation}");
|
||||
assert!(changed > w * h / 4, "grain should cover the fill, not just a few outliers: {changed}");
|
||||
assert!(cream.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_tiles_match_at_fractional_centres_and_rotated_scales() {
|
||||
let (w, h) = (40, 32);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
|
||||
for scale in [0.7, 2.5] {
|
||||
let s = PixelSpace { to_doc: Affine::rotate(0.37) * Affine::scale(scale), px: scale, center: Point::new(12.375, -4.625), ..space(w, h) };
|
||||
let mut whole = image(w, h, |_, _| grey(128));
|
||||
grain.apply(&mut whole, w, h, &s);
|
||||
let (tw, th) = (19, 13);
|
||||
let mut tile = image(tw, th, |_, _| grey(128));
|
||||
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: s.to_doc * Affine::translate((7.0, 5.0)), ..s });
|
||||
for y in 0..th {
|
||||
for x in 0..tw {
|
||||
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 7, y + 5), "fractional rotated tile seam at scale {scale}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_minification_quiets_the_noise_instead_of_aliasing_at_full_strength() {
|
||||
let (w, h) = (64, 64);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
let spread = |scale| {
|
||||
let mut out = image(w, h, |_, _| grey(128));
|
||||
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, ..space(w, h) });
|
||||
out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(128))).sum::<f64>() / (w * h) as f64
|
||||
};
|
||||
let normal = spread(1.0);
|
||||
assert!(normal > 2.0);
|
||||
assert!(spread(2.0) < normal && spread(4.0) < normal / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_sampling_modes_blend_without_a_zoom_threshold_pop() {
|
||||
let (w, h) = (64, 48);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
for boundary in [0.5, 0.75, 1.0, 1.5, 2.0] {
|
||||
// Hold sample coordinates fixed to isolate the footprint-mode boundary. Changing
|
||||
// to_doc as well moves the texture, which can exceed this limit far from the origin.
|
||||
let render = |scale| {
|
||||
let mut out = image(w, h, |_, _| grey(128));
|
||||
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(boundary), px: scale, ..space(w, h) });
|
||||
out
|
||||
};
|
||||
let (left, right) = (render(boundary - 1e-4), render(boundary + 1e-4));
|
||||
let change = left.iter().zip(right).map(|(a, b)| f64::from(a.abs_diff(b))).sum::<f64>() / left.len() as f64;
|
||||
assert!(change < 0.25, "no sudden grain change around px={boundary}: {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_coarse_variance_tracks_an_independent_dense_footprint_average() {
|
||||
let (w, h, samples) = (32, 32, 16);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
let deviation = |values: &[f64]| {
|
||||
let mean = values.iter().sum::<f64>() / values.len() as f64;
|
||||
(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64).sqrt()
|
||||
};
|
||||
for scale in [1.0, 2.0, 4.0, 8.0] {
|
||||
let s = PixelSpace {
|
||||
to_doc: Affine::scale(scale),
|
||||
px: scale,
|
||||
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
|
||||
..space(w, h)
|
||||
};
|
||||
let mut actual = image(w, h, |_, _| grey(128));
|
||||
grain.apply(&mut actual, w, h, &s);
|
||||
// 16×16 independent point samples per coarse pixel; the finest raster never takes
|
||||
// the footprint branch, so this does not reproduce the filter's quadrature weights.
|
||||
let fine_scale = scale / samples as f64;
|
||||
let mut fine = image(w * samples, h * samples, |_, _| grey(128));
|
||||
grain.apply(&mut fine, w * samples, h * samples, &PixelSpace { to_doc: Affine::scale(fine_scale), px: fine_scale, ..s });
|
||||
let mut reference = Vec::new();
|
||||
let mut coarse = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut sum = 0u32;
|
||||
for dy in 0..samples {
|
||||
for dx in 0..samples {
|
||||
sum += u32::from(at(&fine, w * samples, x * samples + dx, y * samples + dy)[0]);
|
||||
}
|
||||
}
|
||||
reference.push(f64::from(sum) / (samples * samples) as f64);
|
||||
coarse.push(f64::from(at(&actual, w, x, y)[0]));
|
||||
}
|
||||
}
|
||||
let ratio = deviation(&coarse) / deviation(&reference);
|
||||
assert!((0.75..=1.25).contains(&ratio), "bounded footprint approximation at px={scale}: relative deviation {ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_strength_is_stable_under_subpixel_centre_shifts() {
|
||||
let (w, h) = (192, 128);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
for scale in [0.25, 0.5, 1.0] {
|
||||
let mut deviations = Vec::new();
|
||||
for phase in [0.0, 0.25, 0.5, 0.75] {
|
||||
let mut out = image(w, h, |_, _| grey(128));
|
||||
let s = PixelSpace {
|
||||
to_doc: Affine::scale(scale),
|
||||
px: scale,
|
||||
center: Point::new(w as f64 * scale / 2.0 + phase, h as f64 * scale / 2.0 + phase),
|
||||
..space(w, h)
|
||||
};
|
||||
grain.apply(&mut out, w, h, &s);
|
||||
deviations.push(film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
|
||||
}
|
||||
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max = deviations.iter().copied().fold(0.0, f64::max);
|
||||
assert!(min > 1.0 && max / min < 1.06, "centre phase must not change grain strength at px={scale}: {deviations:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn film_grain_deviation(values: impl Iterator<Item = f64>) -> f64 {
|
||||
let (mut count, mut sum, mut square_sum) = (0.0, 0.0, 0.0);
|
||||
for v in values {
|
||||
count += 1.0;
|
||||
sum += v;
|
||||
square_sum += v * v;
|
||||
}
|
||||
(square_sum / count - (sum / count).powi(2)).max(0.0).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_preserves_byte_values_when_the_grain_is_too_small_to_round() {
|
||||
let (w, h) = (256, 256);
|
||||
let src = image(w, h, |x, y| {
|
||||
let alpha = y as u8;
|
||||
[(x as u8).min(alpha), (255 - x as u8).min(alpha), (x / 2) as u8 % alpha.max(1), alpha]
|
||||
});
|
||||
let mut out = src.clone();
|
||||
// Even the maximum field value cannot change half a premultiplied byte at this strength.
|
||||
fx("artistic.filmGrain", json!({"grain": 0.01})).apply(&mut out, w, h, &space(w, h));
|
||||
assert_eq!(out, src, "invisible noise preserves every colour/coverage combination");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_cmy_and_k_planes_share_the_noise_and_preserve_coverage() {
|
||||
let (w, h) = (64, 48);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 14}));
|
||||
// Complemented inks of a rich black, premultiplied by the same coverage.
|
||||
let mut cmy = image(w, h, |_, _| [70, 80, 90, 200]);
|
||||
let mut k = image(w, h, |_, _| [40, 40, 40, 200]);
|
||||
grain.apply(&mut cmy, w, h, &PixelSpace { channels: Channels::CmyPlane, ..space(w, h) });
|
||||
grain.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
|
||||
let mut changed = 0;
|
||||
for (cmy, k) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
|
||||
assert_eq!((cmy[3], k[3]), (200, 200));
|
||||
assert!(cmy[..3].iter().all(|v| *v <= 200) && k[..3].iter().all(|v| *v <= 200));
|
||||
assert_eq!(k[0], k[1]);
|
||||
assert_eq!(k[1], k[2]);
|
||||
let (dc, dk) = (i16::from(cmy[0]) - 70, i16::from(k[0]) - 40);
|
||||
if dc != 0 && dk != 0 {
|
||||
assert_eq!(dc.signum(), dk.signum(), "the same field modulates both ink planes");
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
assert!(changed > w * h / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_does_not_imprint_a_periodic_contrast_grid_at_close_zoom() {
|
||||
let (w, h) = (256, 256);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
for (scale, period) in [(0.25, 4), (0.125, 8)] {
|
||||
let mut out = image(w, h, |_, _| grey(128));
|
||||
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, center: Point::new(32.0, 32.0), ..space(w, h) });
|
||||
// Bin by location within a document-unit lattice cell. An axis-aligned value field
|
||||
// puts stronger noise at corners than between them; rotated normalised fields do not.
|
||||
let mut deviations = Vec::new();
|
||||
for dy in 0..period {
|
||||
for dx in 0..period {
|
||||
let values = (dy..h).step_by(period).flat_map(|y| (dx..w).step_by(period).map(move |x| (x, y)));
|
||||
deviations.push(film_grain_deviation(values.map(|(x, y)| f64::from(at(&out, w, x, y)[0]))));
|
||||
}
|
||||
}
|
||||
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max = deviations.iter().copied().fold(0.0, f64::max);
|
||||
assert!(max / min < 1.12, "no visible cell-contrast grid at px={scale}: min={min}, max={max}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_strength_falls_smoothly_across_noninteger_zoom_levels() {
|
||||
let (w, h) = (128, 96);
|
||||
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
|
||||
let mut previous = f64::INFINITY;
|
||||
for scale in [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0] {
|
||||
let mut out = image(w, h, |_, _| grey(128));
|
||||
grain.apply(
|
||||
&mut out,
|
||||
w,
|
||||
h,
|
||||
&PixelSpace {
|
||||
to_doc: Affine::scale(scale),
|
||||
px: scale,
|
||||
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
|
||||
..space(w, h)
|
||||
},
|
||||
);
|
||||
let deviation = film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
|
||||
assert!(deviation <= previous * 1.06, "grain must not gain contrast when zooming out at px={scale}: {deviation} after {previous}");
|
||||
previous = deviation;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_grain_leaves_invalid_coordinate_spaces_alone() {
|
||||
let (w, h) = (8, 8);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
for invalid in [
|
||||
PixelSpace { px: 0.0, ..space(w, h) },
|
||||
PixelSpace { px: f64::NAN, ..space(w, h) },
|
||||
PixelSpace { to_doc: Affine::scale(0.0), ..space(w, h) },
|
||||
PixelSpace { center: Point::new(f64::INFINITY, 0.0), ..space(w, h) },
|
||||
] {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.filmGrain", json!({})).apply(&mut out, w, h, &invalid);
|
||||
assert_eq!(out, src);
|
||||
}
|
||||
let mut out = src.clone();
|
||||
PixelFx::FilmGrain { grain: f64::NAN, highlight: 0.0, intensity: 10.0 }.apply(&mut out, w, h, &space(w, h));
|
||||
assert_eq!(out, src, "directly constructed non-finite parameters are harmless too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
|
||||
|
||||
@@ -189,6 +189,18 @@ 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)> {
|
||||
@@ -196,15 +208,17 @@ fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i
|
||||
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.
|
||||
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
|
||||
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 d = (p.x - u).hypot(p.y - v);
|
||||
if d < best[0].0 {
|
||||
best = [(d, Some(p)), best[0]];
|
||||
} else if d < best[1].0 {
|
||||
best[1] = (d, Some(p));
|
||||
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?))
|
||||
@@ -574,3 +588,53 @@ pub(super) fn texturizer(
|
||||
*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)
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
||||
|
||||
fn square_node() -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
crate::new_effect(id, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
/// The shaded faces of every surface of `art`, bottom first.
|
||||
fn faces(art: &Node) -> Vec<&Node> {
|
||||
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
for id in SOLIDS {
|
||||
let mut n = square_node();
|
||||
let source = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
}
|
||||
// The art follows the source when it moves.
|
||||
let before = art.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((50.0, -30.0)), false);
|
||||
let after = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}");
|
||||
// Hidden, the effect draws nothing of its own.
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(solid_art(&n).is_none(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(ROTATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
// The fill's plane and the stroke's outline, each in its own paint.
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]);
|
||||
assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below");
|
||||
assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above");
|
||||
// Turned 60° about y with no perspective, the stroke's outline spans half its width plus the
|
||||
// stroke's own outset, foreshortened as well.
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
n.opacity = 0.5;
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
f.effects.push(fx(INFLATE, json!({})));
|
||||
}
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(art.opacity, 0.5);
|
||||
let surfaces = art.children().unwrap();
|
||||
// The fill inflated, the stroke drawn flat as it was.
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!(surfaces[0].opacity, 0.4);
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0));
|
||||
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
for id in SOLIDS {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
|
||||
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &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), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 2, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
|
||||
/// their own ids, no 3D effect left on them.
|
||||
#[test]
|
||||
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
|
||||
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let expanded = 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), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&expanded, &mut ids);
|
||||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
assert!(art.appearance.effects.iter().all(|e| e.id != "path.offsetPath" && !is_three_d(&e.id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
let n = square_node();
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
|
||||
let text = Node::group(NodeId(2), vec![]);
|
||||
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
|
||||
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
|
||||
assert!(crate::is_length(id, key, false), "{id} {key}");
|
||||
}
|
||||
assert!(!crate::is_length(EXTRUDE, "twist", false));
|
||||
for id in SOLIDS {
|
||||
assert!(!crate::is_geometry(id) && is_three_d(id));
|
||||
assert!(crate::default_params(id).is_some());
|
||||
}
|
||||
// Options read from parameters, with the documented ranges' defaults.
|
||||
let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99}));
|
||||
assert_eq!(o.taper, 0.4);
|
||||
let b = o.bevel.unwrap();
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_reads_twist_taper_and_cap() {
|
||||
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
|
||||
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
|
||||
let d = inflate_options(&json!({}));
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
"{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?, target?} remove those fills/strokes from each target object's own stack",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?} remove those fills/strokes from each target object's own stack",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Effect",
|
||||
[],
|
||||
None,
|
||||
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
|
||||
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
|
||||
has_doc,
|
||||
super::effectcmd::apply
|
||||
),
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?, target?} copy fills/strokes with their effects",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?|id?, target?} copy fills/strokes with their effects",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", to, contents?: count, ids?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target Contents",
|
||||
[],
|
||||
None,
|
||||
"{ids?} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
|
||||
has_doc,
|
||||
target_contents
|
||||
),
|
||||
@@ -318,7 +318,8 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
|
||||
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
|
||||
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
|
||||
/// Nothing targeted is not an edit.
|
||||
/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and
|
||||
/// its undo history stay as they are.
|
||||
pub(crate) fn edit_items(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
@@ -328,10 +329,12 @@ pub(crate) fn edit_items(
|
||||
fill: bool,
|
||||
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(()))
|
||||
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
|
||||
}
|
||||
|
||||
/// [`edit_items`], then `after` on the ids `f` changed, inside the same undo step.
|
||||
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
|
||||
/// whether it changed the document; when it did not and `f` left every target as it was, the
|
||||
/// document and its undo history stay as they are.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn edit_items_then(
|
||||
s: &mut Session,
|
||||
@@ -341,21 +344,29 @@ pub(crate) fn edit_items_then(
|
||||
label: &str,
|
||||
fill: bool,
|
||||
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<()>,
|
||||
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
|
||||
) -> Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
s.edit(label, |d, _| {
|
||||
let mut changed = Vec::new();
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
for id in ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.resolve(&n.appearance, fill, cmd)?;
|
||||
f(n, index)?;
|
||||
changed.push(*id);
|
||||
edited.push(*id);
|
||||
}
|
||||
after(d, &changed)
|
||||
})
|
||||
let more = after(d, &edited)?;
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many of `ids` differ in `d` from `before`.
|
||||
pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_doc::Document, ids: &[NodeId]) -> usize {
|
||||
ids.iter().filter(|id| d.node(**id) != before.node(**id)).count()
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
@@ -428,13 +439,15 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
|
||||
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = require_targets(s, p, "appearance.clear")?;
|
||||
s.edit("Clear Appearance", |d, _| {
|
||||
// When every object is cleared already, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Clear Appearance", |d| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
clear(n);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -443,7 +456,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
|
||||
/// group keeps no fill or stroke rows of its own.
|
||||
fn clear(n: &mut Node) {
|
||||
n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) };
|
||||
n.appearance = if is_group(n) { Appearance::default().into() } else { Appearance::basic(Paint::None, Paint::None, 1.0).into() };
|
||||
n.opacity = 1.0;
|
||||
n.blend = BlendMode::Normal;
|
||||
}
|
||||
@@ -456,7 +469,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`source` and `target` are the same object"));
|
||||
}
|
||||
let copy = bool_or(p, "copy", false);
|
||||
s.edit(if copy { "Copy Appearance" } else { "Move Appearance" }, |d, _| {
|
||||
// When both objects come out as they were, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
||||
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
|
||||
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
|
||||
let mut ap = src.appearance.clone();
|
||||
@@ -473,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clear(n);
|
||||
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &[source, target]))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -486,7 +501,9 @@ fn is_group(n: &Node) -> bool {
|
||||
|
||||
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
|
||||
s.edit("Reduce to Basic Appearance", |d, _| {
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
// The topmost visible fill and stroke stay, without their own transparency or effects.
|
||||
@@ -496,7 +513,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// keeps the rows it had.
|
||||
let fill =
|
||||
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone()))));
|
||||
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() };
|
||||
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() }.into();
|
||||
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
|
||||
st.effects.clear();
|
||||
st.opacity = 1.0;
|
||||
@@ -505,7 +522,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -519,9 +536,17 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("appearance.setItem", "select objects or give ids"));
|
||||
}
|
||||
let blend = str_param(p, "blend").and_then(BlendMode::parse);
|
||||
s.edit("Appearance", |d, _| {
|
||||
// When every object comes out as it was (for example, when none has item `index`), the document
|
||||
// and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if p.get("weight").is_none()
|
||||
&& let Some(pa) = &paint
|
||||
{
|
||||
super::paint::restore_unpainted_stroke(&mut n.appearance, Some(idx), pa);
|
||||
}
|
||||
let bounds = n.geometric_bounds();
|
||||
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
|
||||
let (pp, op, bl, vis, bounds) = match item {
|
||||
@@ -547,7 +572,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*vis = v;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
if let Some(pa) = &paint {
|
||||
s.remember_paint(pa);
|
||||
@@ -574,7 +599,10 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = item_indices(p, "appearance.removeItem")?;
|
||||
let ids = require_targets(s, p, "appearance.removeItem")?;
|
||||
s.edit("Remove Item", |d, _| {
|
||||
// When no object has an item at any of the indices, the document and its undo history stay as
|
||||
// they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Remove Item", |d| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
// From the top down, so the lower indices stay valid.
|
||||
@@ -583,7 +611,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
@@ -648,7 +676,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = index_param(p, "from", C)?;
|
||||
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
|
||||
let mut landed = to;
|
||||
s.edit("Reorder Appearance", |d, _| {
|
||||
// When every object's stack comes out as it was, the document and its undo history stay as they
|
||||
// are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
|
||||
@@ -656,7 +687,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.appearance.set_contents_at(k);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
@@ -676,14 +707,18 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
|
||||
/// has one above its bottom `to` items.
|
||||
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
let slot = s.edit("Reorder Appearance", |d, _| {
|
||||
let mut slot = None;
|
||||
// When every Contents row is above the bottom `to` items already, the document and its undo
|
||||
// history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut slot = None;
|
||||
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
|
||||
for id in ids {
|
||||
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
|
||||
n.appearance.set_contents_at(to);
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
}
|
||||
@@ -728,13 +763,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if targets.is_empty() || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
s.edit("Eyedropper", |d, _| {
|
||||
// When every target comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
picked.apply(n, &attrs, append);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(count_changed(d, &before, &targets))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -758,7 +795,7 @@ impl Picked {
|
||||
let st = &t.runs[0].style;
|
||||
(st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone())))
|
||||
}
|
||||
_ => (n.appearance.clone(), n.geometric_bounds(), None),
|
||||
_ => ((*n.appearance).clone(), n.geometric_bounds(), None),
|
||||
};
|
||||
Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text }
|
||||
}
|
||||
@@ -831,7 +868,7 @@ impl Picked {
|
||||
}
|
||||
let placed = self.placed(n.geometric_bounds());
|
||||
if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL {
|
||||
n.appearance = placed;
|
||||
n.appearance = placed.into();
|
||||
} else {
|
||||
focal_attrs(&mut n.appearance, &placed, ap);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Attributes",
|
||||
[],
|
||||
None,
|
||||
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
|
||||
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill Rule",
|
||||
[],
|
||||
None,
|
||||
"{rule: \"nonZero\"|\"evenOdd\", ids?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
|
||||
"{rule: \"nonZero\"|\"evenOdd\", ids?|id?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
|
||||
has_doc,
|
||||
set_fill_rule
|
||||
),
|
||||
@@ -69,16 +69,26 @@ fn ruled<'a>(n: &'a Node, out: &mut Vec<(&'a Node, FillRule)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A directly selected compound member fills with its compound's rule. Its other attributes
|
||||
/// and path direction still belong to the member itself.
|
||||
fn fill_rule_owner(d: &Document, id: NodeId) -> Option<&Node> {
|
||||
let n = d.node(id)?;
|
||||
match d.parent_of(id).and_then(|parent| d.node(parent)) {
|
||||
Some(parent) if matches!(parent.kind, NodeKind::Compound { .. }) => Some(parent),
|
||||
_ => Some(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes panel's values of `info.ids` beyond overprinting.
|
||||
pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) {
|
||||
let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect();
|
||||
let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default());
|
||||
let attrs = || nodes.iter().map(|n| n.extras().attrs.as_deref().cloned().unwrap_or_default());
|
||||
info.show_center = same(nodes.iter().map(|n| n.shows_center()));
|
||||
info.image_map = same(attrs().map(|a| a.image_map));
|
||||
info.url = same(attrs().map(|a| a.url));
|
||||
info.note = same(attrs().map(|a| a.note));
|
||||
let mut all = vec![];
|
||||
nodes.iter().for_each(|n| ruled(n, &mut all));
|
||||
nodes.iter().filter_map(|n| fill_rule_owner(d, n.id)).for_each(|n| ruled(n, &mut all));
|
||||
info.fill_rule = same(all.into_iter().map(|(_, r)| r));
|
||||
let paths = paths_in(d, &info.ids);
|
||||
let subpaths = paths.iter().filter_map(|id| d.node(*id)?.path_data()).flat_map(|p| &p.subpaths);
|
||||
@@ -116,7 +126,7 @@ impl Edits {
|
||||
|
||||
/// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default).
|
||||
fn apply(&self, n: &Node) -> ObjectAttributes {
|
||||
let mut a = n.attrs.as_deref().cloned().unwrap_or_default();
|
||||
let mut a = n.extras().attrs.as_deref().cloned().unwrap_or_default();
|
||||
if let Some(v) = self.show_center {
|
||||
a.show_center = (v != n.shows_center_by_default()).then_some(v);
|
||||
}
|
||||
@@ -148,7 +158,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let new = edits.apply(n);
|
||||
if new != n.attrs.as_deref().cloned().unwrap_or_default()
|
||||
if new != n.extras().attrs.as_deref().cloned().unwrap_or_default()
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| *a = new);
|
||||
@@ -172,7 +182,7 @@ fn set_fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let changed = edit_counted(s, "Fill Rule", |d| {
|
||||
let mut all = vec![];
|
||||
ids.iter().filter_map(|id| d.node(*id)).for_each(|n| ruled(n, &mut all));
|
||||
ids.iter().filter_map(|id| fill_rule_owner(d, *id)).for_each(|n| ruled(n, &mut all));
|
||||
let todo: BTreeSet<_> = all.into_iter().filter(|(_, r)| *r != rule).map(|(n, _)| n.id).collect();
|
||||
for id in &todo {
|
||||
if let Some(NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
|
||||
@@ -15,6 +15,7 @@ use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKin
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -33,11 +34,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Brush",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}",
|
||||
"{name, ids?|id?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}",
|
||||
has_doc,
|
||||
brush_apply
|
||||
),
|
||||
cmd!("brush.remove", "Remove Brush Stroke", [], None, "{ids?} remove brushes from the selected strokes → {ids}", has_selection, brush_remove),
|
||||
cmd!(
|
||||
"brush.remove",
|
||||
"Remove Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} remove brushes from the selected strokes → {ids}",
|
||||
has_selection,
|
||||
brush_remove
|
||||
),
|
||||
cmd!(
|
||||
"brush.setCurrent",
|
||||
"Current Brush",
|
||||
@@ -52,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Brush…",
|
||||
[],
|
||||
None,
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
@@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Brush Strokes",
|
||||
[],
|
||||
None,
|
||||
"{ids?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
|
||||
"{ids?|id?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
|
||||
has_selection,
|
||||
expand_brush
|
||||
),
|
||||
@@ -81,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paintbrush",
|
||||
[],
|
||||
None,
|
||||
"{…path.freehand params, brush} paint a freehand stroke with a brush → {id}",
|
||||
"{…params as path.freehand, brush} paint a freehand stroke with a brush → {id}",
|
||||
has_doc,
|
||||
brush_freehand
|
||||
),
|
||||
@@ -92,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?} make a symbol from the selection and replace it with an instance → {name, id}",
|
||||
"{name?, ids?|id?} make a symbol from the selection and replace it with an instance → {name, id}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -110,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?} expand selected instances into plain art → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
@@ -119,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Symbol",
|
||||
[],
|
||||
None,
|
||||
"{id?} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}",
|
||||
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}",
|
||||
has_selection,
|
||||
symbol_edit
|
||||
),
|
||||
@@ -128,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Redefine Symbol",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
|
||||
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
|
||||
has_selection,
|
||||
symbol_update
|
||||
),
|
||||
@@ -147,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Symbol",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} swap the symbol of the selected instances → {ids}",
|
||||
"{name, ids?|id?} swap the symbol of the selected instances → {ids}",
|
||||
has_selection,
|
||||
symbol_replace
|
||||
),
|
||||
@@ -302,7 +311,7 @@ fn brush_set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The selection as one art node (a group for several objects), ids kept.
|
||||
fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
|
||||
pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = roots(s, p)?;
|
||||
let nodes: Vec<Node> = ids.iter().filter_map(|id| d.node(*id).cloned()).collect();
|
||||
@@ -517,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
Ok((art, (w, h), c))
|
||||
}
|
||||
|
||||
fn unique_symbol_name(d: &Document, base: &str) -> String {
|
||||
let taken = |n: &str| d.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 instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -561,6 +562,10 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
|
||||
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
if separate(d, ids) {
|
||||
let id = d.alloc_id();
|
||||
return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?);
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?;
|
||||
@@ -574,7 +579,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
@@ -734,9 +739,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if expand {
|
||||
break_links(d, &inst)?;
|
||||
} else {
|
||||
for i in inst {
|
||||
d.remove(i)?;
|
||||
}
|
||||
remove_each(d, &inst)?;
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
@@ -753,7 +756,7 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
@@ -863,12 +866,7 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let n = doomed.len();
|
||||
if n > 0 {
|
||||
s.edit("Symbol Sprayer", |d, _| {
|
||||
for id in &doomed {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?;
|
||||
}
|
||||
return Ok(json!({ "count": n }));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -26,7 +26,7 @@ use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::shape_node;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder Regions",
|
||||
[],
|
||||
None,
|
||||
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
|
||||
"{ids?|id?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
|
||||
has_selection_or_ids,
|
||||
sb_regions
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?} build a Live Paint group from the selected paths → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{id?} replace Live Paint groups with their original paths → {ids}",
|
||||
"{id?, ids?: [id…] (instead of id)} replace Live Paint groups with their original paths → {ids}",
|
||||
has_selection_or_ids,
|
||||
lp_release
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{id?} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
|
||||
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
|
||||
has_selection_or_ids,
|
||||
lp_expand
|
||||
),
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill)} fill the face containing point → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket (Stroke)",
|
||||
[],
|
||||
None,
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4)} paint the edge nearest point → {group, edge}",
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
|
||||
has_doc,
|
||||
lp_stroke_edge
|
||||
),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make and Expand",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"same as imageTrace.make, then expand → {id, paths, anchors, colors}",
|
||||
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, true)
|
||||
),
|
||||
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?} put the source image back in place of Image Trace groups → {ids}",
|
||||
"{id?, ids?: [id…] (instead of id)} put the source image back in place of Image Trace groups → {ids}",
|
||||
has_selection_or_ids,
|
||||
trace_release
|
||||
),
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?} keep only the traced paths (drop the source image) → {ids}",
|
||||
"{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}",
|
||||
has_selection_or_ids,
|
||||
trace_expand
|
||||
),
|
||||
@@ -341,19 +341,16 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
|
||||
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
|
||||
for r in roots {
|
||||
let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let ids = nodes.iter().map(|n| n.id).collect();
|
||||
let mut replaced = Beside::replacing(d, roots);
|
||||
for r in rest {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
replaced.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let at = idx - before;
|
||||
let mut ids = vec![];
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
ids.push(d.insert(par, at + k, n)?);
|
||||
}
|
||||
replaced.put(d, top, nodes)?;
|
||||
replaced.finish(d)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -478,7 +475,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|e| {
|
||||
let pd = e.path_data()?;
|
||||
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), e.appearance.clone()))
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
|
||||
})
|
||||
.collect();
|
||||
let mut sources = source_nodes(&old);
|
||||
@@ -505,17 +502,17 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
|
||||
{
|
||||
Arc::make_mut(e).appearance = app;
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, &roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
d.insert(par, idx, g)?;
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
})?;
|
||||
@@ -536,11 +533,14 @@ fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = live_targets(s, p)?;
|
||||
let ids = s.edit("Release Live Paint", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
|
||||
out.extend(replace_roots(d, &[*g], srcs)?);
|
||||
out.extend(srcs.iter().map(|c| c.id));
|
||||
released.put(d, *g, srcs)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.trace = None;
|
||||
n.extras_mut().trace = None;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| match c.name.as_deref() {
|
||||
Some(SOURCES_NAME) => false,
|
||||
@@ -703,6 +703,66 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
|
||||
use vectorcraft_geom::Affine;
|
||||
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
|
||||
let mut live = match shape {
|
||||
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
|
||||
w: width,
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
let _ = live.transform(xf);
|
||||
live
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut roots = vec![];
|
||||
for i in 0..n {
|
||||
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
|
||||
Some(of) => of.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
// Outermost first, so that backwards each path comes after those inside it.
|
||||
let mut order = roots.clone();
|
||||
let mut at = 0;
|
||||
while let Some(&i) = order.get(at) {
|
||||
order.extend(inside.get(i).into_iter().flatten());
|
||||
at += 1;
|
||||
}
|
||||
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
}
|
||||
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
|
||||
// and they are kept all the same.)
|
||||
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
@@ -734,7 +794,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
.ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))?
|
||||
};
|
||||
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
|
||||
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
|
||||
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
@@ -762,9 +823,16 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
|
||||
let shape = traced_shape(shape, xf);
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
@@ -777,18 +845,20 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
None => fill_only(paint),
|
||||
}
|
||||
.into(),
|
||||
None => fill_only(paint).into(),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
traced.push(n);
|
||||
}
|
||||
children.extend(grouped(d, traced, &res.parents));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
if !expand {
|
||||
// Tracing again keeps the object's view unless another is asked for.
|
||||
let view = view.unwrap_or_else(|| src.trace_view());
|
||||
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
|
||||
g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
@@ -819,10 +889,10 @@ fn trace_set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Image Trace View", |d, _| {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) {
|
||||
if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) {
|
||||
t.insert("view".into(), json!(view.id()));
|
||||
} else {
|
||||
n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
|
||||
n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -834,12 +904,15 @@ fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = trace_targets(s, p)?;
|
||||
let ids = s.edit("Release Image Trace", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
|
||||
img.visible = true;
|
||||
out.extend(replace_roots(d, &[*g], vec![img])?);
|
||||
out.push(img.id);
|
||||
released.put(d, *g, vec![img])?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -852,7 +925,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.trace = None;
|
||||
n.extras_mut().trace = None;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| !matches!(c.kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
@@ -142,8 +142,8 @@ impl Clipboard {
|
||||
d.patterns = self.patterns.clone();
|
||||
d.swatches.extend(self.swatches.iter().cloned());
|
||||
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
|
||||
d.char_styles = self.char_styles.clone();
|
||||
d.para_styles = self.para_styles.clone();
|
||||
d.char_styles = self.char_styles.clone().into();
|
||||
d.para_styles = self.para_styles.clone().into();
|
||||
if !self.brushes.is_empty() {
|
||||
let mut lib = vectorcraft_brush::library(&d);
|
||||
for b in &self.brushes {
|
||||
@@ -248,6 +248,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
|
||||
/// markup with words in it is text.
|
||||
#[test]
|
||||
fn markup_of_only_images_names_a_picture() {
|
||||
for m in [
|
||||
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
|
||||
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
|
||||
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
|
||||
r#"<a href="https://x.org"><img src="a.png"></a>"#,
|
||||
"https://example.com/cat.png",
|
||||
] {
|
||||
assert!(names_a_picture(m), "{m}");
|
||||
}
|
||||
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
|
||||
assert!(!names_a_picture(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_exports_svg_that_round_trips_through_paste() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
|
||||
//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
@@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*";
|
||||
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
|
||||
/// where the platform offers it).
|
||||
pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
@@ -39,17 +42,42 @@ pub fn is_address(text: &str) -> bool {
|
||||
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
|
||||
}
|
||||
|
||||
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
|
||||
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
|
||||
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
|
||||
pub fn names_a_picture(text: &str) -> bool {
|
||||
is_address(text) || only_images(text)
|
||||
}
|
||||
|
||||
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
|
||||
/// between them aside), and at least one image?
|
||||
fn only_images(html: &str) -> bool {
|
||||
let mut images = 0;
|
||||
let mut rest = html.trim();
|
||||
while !rest.is_empty() {
|
||||
let Some(tag) = rest.strip_prefix('<') else { return false };
|
||||
let Some(end) = tag.find('>') else { return false };
|
||||
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
|
||||
if name.eq_ignore_ascii_case("img") {
|
||||
images += 1;
|
||||
}
|
||||
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
|
||||
}
|
||||
images > 0
|
||||
}
|
||||
|
||||
/// How much of a copied file [`file_flavour`] needs to tell what it is.
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -143,6 +171,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
import_emf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importEps",
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -291,7 +328,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image =
|
||||
ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() };
|
||||
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() };
|
||||
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() };
|
||||
clip.images.insert(img.key, img.blob);
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h }))
|
||||
@@ -321,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if vectorcraft_metafile::sniff(&bytes).is_none() {
|
||||
return Err(bad(C, "the data isn't an EMF or WMF picture"));
|
||||
}
|
||||
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
import_picture(s, p, C, "Clipboard.emf", &bytes)
|
||||
}
|
||||
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
loaded.doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the picture has no objects"));
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": loaded.warnings }))
|
||||
|
||||
@@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map<String, Value>, f: Visit) {
|
||||
fn node_refs(n: &Node, f: Visit) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_refs(&m.appearance, f);
|
||||
if let Some(id) = m.graphic_style {
|
||||
if let Some(id) = m.extras().graphic_style {
|
||||
f(Ref::Style(id));
|
||||
}
|
||||
if let Some(mask) = &m.mask {
|
||||
if let Some(mask) = &m.extras().mask {
|
||||
node_refs(&mask.art, f);
|
||||
}
|
||||
match &m.kind {
|
||||
@@ -427,8 +427,8 @@ impl Relink {
|
||||
/// Relink a node tree (the mirror of [`node_refs`]).
|
||||
fn node(&self, n: &mut Node) {
|
||||
self.appearance(&mut n.appearance);
|
||||
n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied());
|
||||
if let Some(m) = &mut n.mask {
|
||||
n.extras_mut().graphic_style = n.extras().graphic_style.and_then(|id| self.styles.get(&id).copied());
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
self.node(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
match &mut n.kind {
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Color Guide",
|
||||
[],
|
||||
None,
|
||||
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name, or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
|
||||
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name; or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
|
||||
always,
|
||||
harmony
|
||||
),
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Document Color Mode",
|
||||
[],
|
||||
None,
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
"{…as file.documentColorMode} same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
has_doc,
|
||||
convert_mode
|
||||
),
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gamut Warning",
|
||||
[],
|
||||
None,
|
||||
"{ids?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
|
||||
"{ids?|id?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
|
||||
has_doc,
|
||||
gamut_check
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Theme",
|
||||
["Window", "Color Themes"],
|
||||
None,
|
||||
"{colors: [1–5 colours] | color + rule (a harmony rule id or label, see color.harmony: its 5-colour theme from that base colour, base first), rule?: the rule it was made with, name?: (default \"Theme N\"), replace?: a saved theme's name (overwrite that theme in place, e.g. after editing it; it may be renamed)} save a colour theme to the local theme library (the preferences) → {name, count: themes saved}",
|
||||
"{colors: [1–5 colours] | color + rule (a harmony rule id or label, see color.harmony: its 5-colour theme from that base colour, base first), rule?: the rule it was made with, name?: (default \"Theme N\"), replace?: a saved theme's name (overwrite that theme in place, e.g. after editing it; it may be renamed), color?: one colour (as colors: [colour])} save a colour theme to the local theme library (the preferences) → {name, count: themes saved}",
|
||||
always,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
||||
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
|
||||
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
|
||||
cmd!(
|
||||
"shape.rectangularGrid",
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
cmd!(
|
||||
"shape.polarGrid",
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
cmd!(
|
||||
"path.create",
|
||||
"Create Path",
|
||||
@@ -58,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -97,7 +113,7 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
|
||||
let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?;
|
||||
let mut mask = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?;
|
||||
mask.id = d.alloc_id();
|
||||
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
|
||||
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0).into();
|
||||
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
@@ -157,7 +173,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn path_kind(path: PathData, live: Option<LiveShape>) -> NodeKind {
|
||||
NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false }
|
||||
NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false }
|
||||
}
|
||||
|
||||
fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
|
||||
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
}
|
||||
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let children = paths
|
||||
.into_iter()
|
||||
.map(|pd| {
|
||||
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
|
||||
let mut n = if pd.subpaths.len() > 1 {
|
||||
let members =
|
||||
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
|
||||
let children = members.collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
|
||||
} else {
|
||||
Node::path(d.alloc_id(), pd, Appearance::default())
|
||||
};
|
||||
look.apply(d, &mut n);
|
||||
std::sync::Arc::new(n)
|
||||
})
|
||||
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
|
||||
};
|
||||
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
|
||||
}
|
||||
|
||||
/// A grid's on/off option `key` (`default` when absent).
|
||||
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangularGrid")?;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths =
|
||||
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
|
||||
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.polarGrid")?;
|
||||
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths = shapes::polar_grid(
|
||||
r,
|
||||
dividers(p, "concentric", "concentricSkew", CMD)?,
|
||||
dividers(p, "radial", "radialSkew", CMD)?,
|
||||
switch(p, "compound", false, CMD)?,
|
||||
);
|
||||
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
|
||||
@@ -451,7 +502,9 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Lays the text out (and sizes an Auto Size frame).
|
||||
super::typecmd::refresh_bounds(&mut t);
|
||||
}
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
}
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
|
||||
@@ -17,17 +17,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"CSS Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
|
||||
"{ids?|id?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
|
||||
has_doc,
|
||||
selection
|
||||
),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…css.selection options} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(
|
||||
"css.export",
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …css.selection options} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::pathops::{Beside, node_path};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -163,10 +163,12 @@ fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Liquify",
|
||||
[],
|
||||
None,
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}",
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
has_doc,
|
||||
liquify
|
||||
),
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Puppet Warp",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
has_doc,
|
||||
puppet_warp
|
||||
),
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Perspective Grid",
|
||||
[],
|
||||
None,
|
||||
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
|
||||
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool, …the other fields of the grid as its reply lists them} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
|
||||
has_doc,
|
||||
grid_set
|
||||
),
|
||||
@@ -123,17 +123,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Attach to Active Plane",
|
||||
[],
|
||||
None,
|
||||
"{ids?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane",
|
||||
"{ids?|id?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane",
|
||||
has_doc,
|
||||
attach
|
||||
),
|
||||
cmd!("perspective.release", "Release with Perspective", [], None, "{ids?} detach from the grid (geometry unchanged)", has_doc, release),
|
||||
cmd!("perspective.release", "Release with Perspective", [], None, "{ids?|id?} detach from the grid (geometry unchanged)", has_doc, release),
|
||||
cmd!(
|
||||
"perspective.move",
|
||||
"Move in Perspective",
|
||||
[],
|
||||
None,
|
||||
"{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
|
||||
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
|
||||
has_doc,
|
||||
persp_move
|
||||
),
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Draw in Perspective",
|
||||
[],
|
||||
None,
|
||||
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
|
||||
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid), at?: [x, y] (a point of the plane the shape lies on, as the tool gives it)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
|
||||
has_doc,
|
||||
persp_draw
|
||||
),
|
||||
@@ -151,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform in Perspective",
|
||||
[],
|
||||
None,
|
||||
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
|
||||
"{ids?|id?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
|
||||
has_doc,
|
||||
persp_transform
|
||||
),
|
||||
@@ -266,6 +266,17 @@ fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f6
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` hold the same width points, each place and width within a rounding error
|
||||
/// (1e-9).
|
||||
fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
|
||||
let near = |p: &(f64, f64, f64), q: &(f64, f64, f64)| (p.0 - q.0).abs() <= 1e-9 && (p.1 - q.1).abs() <= 1e-9 && (p.2 - q.2).abs() <= 1e-9;
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a.points.len() == b.points.len() && a.points.iter().zip(&b.points).all(|(p, q)| near(p, q)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.set";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
@@ -275,12 +286,16 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "left/right must be non-negative widths"));
|
||||
}
|
||||
let adjust = bool_or(p, "adjustAdjoining", false);
|
||||
let pos = s.edit("Width Point", |d, _| {
|
||||
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
|
||||
// history stay as they are.
|
||||
let mut pos = 0;
|
||||
super::overprint::edit_counted(s, "Width Point", |d| {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let half = st.width / 2.0;
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let pt = (t, left / half, right / half);
|
||||
let pos = match point_index(p, &prof, C)? {
|
||||
pos = match point_index(p, &prof, C)? {
|
||||
Some(i) => {
|
||||
let old = prof.points[i];
|
||||
if adjust {
|
||||
@@ -301,7 +316,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
st.profile = Some(prof);
|
||||
Ok(pos)
|
||||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||||
})?;
|
||||
Ok(json!({ "index": pos }))
|
||||
}
|
||||
@@ -310,14 +325,18 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.copy";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
|
||||
let pos = s.edit("Copy Width Point", |d, _| {
|
||||
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
|
||||
// its widths already), the document and its undo history stay as they are.
|
||||
let mut pos = 0;
|
||||
super::overprint::edit_counted(s, "Copy Width Point", |d| {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?;
|
||||
let (from, l, r) = prof.points[i];
|
||||
let pos = place(&mut prof.points, (t, l, r), from < t);
|
||||
pos = place(&mut prof.points, (t, l, r), from < t);
|
||||
st.profile = Some(prof);
|
||||
Ok(pos)
|
||||
Ok(usize::from(st.profile != before))
|
||||
})?;
|
||||
Ok(json!({ "index": pos }))
|
||||
}
|
||||
@@ -373,6 +392,11 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
// When every stroke has the profile already, or there is no stroke, the document and its undo
|
||||
// history stay as they are.
|
||||
if ids.iter().all(|id| doc.node(*id).is_some_and(|n| n.appearance.stroke().is_none_or(|st| st.profile == profile))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Width Profile", |d, _| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -415,7 +439,7 @@ struct Targets {
|
||||
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
|
||||
fn not_liquified(n: &Node) -> Option<&'static str> {
|
||||
Some(match &n.kind {
|
||||
NodeKind::Group { .. } if n.graph.is_some() => "graphs",
|
||||
NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs",
|
||||
NodeKind::Text(_) => "type",
|
||||
NodeKind::SymbolInstance { .. } => "symbols",
|
||||
NodeKind::Image(_) => "images",
|
||||
@@ -602,18 +626,23 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stroke.extend(pts.get(done..).unwrap_or_default());
|
||||
let results = stroke.results();
|
||||
let (skipped, warning) = stroke.skipped();
|
||||
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
|
||||
let mut changed = vec![];
|
||||
for (id, new) in results {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = new;
|
||||
*live = None;
|
||||
changed.push(id.0);
|
||||
// When the stroke reshapes no path, the document and its undo history stay as they are.
|
||||
let changed = if results.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
|
||||
let mut changed = vec![];
|
||||
for (id, new) in results {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = new;
|
||||
*live = None;
|
||||
changed.push(id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(changed)
|
||||
})?
|
||||
};
|
||||
if s.in_interaction() {
|
||||
s.liquify_stroke = Some(stroke);
|
||||
}
|
||||
@@ -659,10 +688,21 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||||
if rest {
|
||||
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
|
||||
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
|
||||
// When the art and its pins come out as they were, the document and its undo history stay
|
||||
// as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Puppet Warp", |d| {
|
||||
warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e))?;
|
||||
Ok(usize::from(d.puppet != before.puppet || ids.iter().any(|id| d.node(*id) != before.node(*id))))
|
||||
})?;
|
||||
return Ok(ids_json);
|
||||
}
|
||||
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
|
||||
// When every pin stays where it is and turns the art by 0° or is free, the document and its undo
|
||||
// history stay as they are.
|
||||
if pins == moved && angles.iter().all(|a| a.is_none_or(|a| a == 0.0)) {
|
||||
return Ok(ids_json);
|
||||
}
|
||||
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
|
||||
let deformed = arap::deform(&mesh, &pins);
|
||||
let f = |q: Point| mesh.map(&deformed, q);
|
||||
@@ -707,6 +747,14 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
|
||||
g.store(d);
|
||||
}
|
||||
|
||||
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
|
||||
/// leaves `d` as it is.
|
||||
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
|
||||
let mut g = g.clone();
|
||||
g.attached.clear();
|
||||
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
|
||||
}
|
||||
|
||||
/// Change view state of the grid without an undo step.
|
||||
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -724,6 +772,11 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
|
||||
let reproject = bool_or(p, "reproject", false);
|
||||
// When the document holds this grid already, no object moves, and the document and its undo
|
||||
// history stay as they are.
|
||||
if grid_kept(&s.doc()?.doc, &g) {
|
||||
return Ok(g.definition_json());
|
||||
}
|
||||
s.edit("Define Perspective Grid", |d, _| {
|
||||
if reproject {
|
||||
old.adopt_stored_attachments(d);
|
||||
@@ -800,6 +853,11 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&g, p, "perspective.attach")?;
|
||||
// With no object to attach, when the document holds the grid already, the document and its undo
|
||||
// history stay as they are.
|
||||
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
|
||||
return Ok(json!({ "ids": [], "plane": plane.id() }));
|
||||
}
|
||||
s.edit("Attach to Active Plane", |d, _| {
|
||||
for id in &ids {
|
||||
attach_in(d, &mut g, *id, plane, false, None)?;
|
||||
@@ -813,6 +871,19 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
// When releasing leaves every object as it is (none is in perspective) and the document holds
|
||||
// the grid already, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
let unchanged = |id: &NodeId| {
|
||||
d.node(*id).is_none_or(|n| {
|
||||
let mut released = n.clone();
|
||||
persp::release(&mut released);
|
||||
released == *n
|
||||
})
|
||||
};
|
||||
if ids.iter().all(unchanged) && grid_kept(d, &g) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release with Perspective", |d, _| {
|
||||
for id in &ids {
|
||||
g.attached.remove(&id.0.to_string());
|
||||
@@ -875,6 +946,19 @@ fn persp_edit(
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
|
||||
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
|
||||
/// holds the grid already; None otherwise.
|
||||
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let g = grid_of(d);
|
||||
if ids.is_empty() || !grid_kept(d, &g) {
|
||||
return Ok(None);
|
||||
}
|
||||
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
|
||||
Ok(at.and_then(|at| at.first().copied()))
|
||||
}
|
||||
|
||||
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
|
||||
/// the drag that made it is committed.
|
||||
fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||||
@@ -921,11 +1005,25 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
|
||||
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
|
||||
// Transform Again repeats the first object's move.
|
||||
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
|
||||
// included) and there is no copy, the document and its undo history stay as they are.
|
||||
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
|
||||
if perp {
|
||||
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
|
||||
} else {
|
||||
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||||
}
|
||||
};
|
||||
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
|
||||
let (targets, first) = match first_still {
|
||||
Some(at) => (ids, Some(at)),
|
||||
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
|
||||
};
|
||||
// Transform Again repeats the first object's move, and the identity when no object moved.
|
||||
if let Some((pl, depth)) = first {
|
||||
let g = grid_of(&s.doc()?.doc);
|
||||
let again = match perp {
|
||||
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
|
||||
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
|
||||
false => {
|
||||
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
|
||||
@@ -955,7 +1053,14 @@ fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
|
||||
};
|
||||
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
|
||||
// An identity without a copy moves no object that is in perspective already: then the document
|
||||
// and its undo history stay as they are, and Transform Again repeats the identity.
|
||||
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
|
||||
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
|
||||
ids
|
||||
} else {
|
||||
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
|
||||
};
|
||||
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
|
||||
Ok(ids_json(&targets))
|
||||
}
|
||||
@@ -992,16 +1097,25 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"move" => "Move Plane and Objects",
|
||||
_ => "Move Plane",
|
||||
};
|
||||
// The objects on the plane where it was (not those moved off it along its normal).
|
||||
let on_plane = |d: &Document| -> Vec<NodeId> {
|
||||
persp::attached_roots(d)
|
||||
.into_iter()
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||||
.map(|(id, ..)| id)
|
||||
.collect()
|
||||
};
|
||||
// A plane moved to where it is, with its objects or without them, moves nothing: when the
|
||||
// document holds the grid already, the document and its undo history stay as they are.
|
||||
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
|
||||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
old.adopt_stored_attachments(d);
|
||||
let mut ids = vec![];
|
||||
if objects != "none" {
|
||||
// The objects on the plane where it was (not those moved off it along its normal).
|
||||
let on: Vec<NodeId> = persp::attached_roots(d)
|
||||
.into_iter()
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||||
.map(|(id, ..)| id)
|
||||
.collect();
|
||||
let on = on_plane(d);
|
||||
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
|
||||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||||
for id in &ids {
|
||||
@@ -1025,6 +1139,11 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
g.set_offset(plane, depth);
|
||||
g.plane = plane;
|
||||
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
|
||||
// When the plane is at the object and active already, and the document holds that grid, the
|
||||
// document and its undo history stay as they are.
|
||||
if grid_kept(&st.doc, &g) {
|
||||
return Ok(json!({ "plane": plane.id(), "offset": depth }));
|
||||
}
|
||||
s.edit("Move Plane to Match Object", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
@@ -1079,6 +1198,7 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = st.history.undo.last_mut() {
|
||||
e.label = "Draw in Perspective".into();
|
||||
}
|
||||
st.history.recost_last(&st.doc);
|
||||
}
|
||||
res?;
|
||||
Ok(json!({ "id": id.0, "plane": plane.id() }))
|
||||
@@ -1132,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
|
||||
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
|
||||
t.transform(shift);
|
||||
if let Some(rec) = n.perspective.as_deref_mut() {
|
||||
if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) {
|
||||
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
|
||||
rec.editing = true;
|
||||
}
|
||||
@@ -1149,8 +1269,10 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// in every undo state (the flat view is never an undo step of its own).
|
||||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
let clear = |doc: &mut Arc<Document>| {
|
||||
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
|
||||
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
|
||||
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
|
||||
// builds (the document now keeps its own).
|
||||
if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing)
|
||||
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()))
|
||||
{
|
||||
p.editing = false;
|
||||
}
|
||||
@@ -1162,6 +1284,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
}
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
clear(&mut e.doc);
|
||||
e.doc.drop_lookup_cache();
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
clear(&mut it.doc);
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Pixel Perfect",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights",
|
||||
"{ids?|id?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights",
|
||||
has_selection,
|
||||
pixel_perfect
|
||||
),
|
||||
@@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spot.insert(name.clone());
|
||||
}
|
||||
}
|
||||
if n.mask.is_some() {
|
||||
if n.extras().mask.is_some() {
|
||||
*counts.entry("opacityMasks").or_default() += 1;
|
||||
}
|
||||
if !n.appearance.effects.is_empty() {
|
||||
|
||||
Loaded 100 of 580 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user