mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 11:46:54 -04:00
Compare commits
845
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0d2448da | ||
|
|
5a120e323b | ||
|
|
5de774835b | ||
|
|
1a280e1402 | ||
|
|
8bd8e2b298 | ||
|
|
cd1b590aa8 | ||
|
|
dcd0da87cb | ||
|
|
f774968b20 | ||
|
|
f1fd8e85e8 | ||
|
|
991ce2c635 | ||
|
|
0efcaeb558 | ||
|
|
17d2493616 | ||
|
|
3ae71c16ae | ||
|
|
ecf86ca21e | ||
|
|
886b33c584 | ||
|
|
2af6f5bfbe | ||
|
|
cde9ee8a71 | ||
|
|
e802ea8438 | ||
|
|
8f8d49110f | ||
|
|
5ecd045156 | ||
|
|
5688294d56 | ||
|
|
c348b384aa | ||
|
|
a6d5c461a1 | ||
|
|
779120cb5b | ||
|
|
2e6b925793 | ||
|
|
48041c8cd3 | ||
|
|
1d282159dd | ||
|
|
0ad92e4649 | ||
|
|
729e5ced7f | ||
|
|
0cb55bc0aa | ||
|
|
277e893e82 | ||
|
|
ea30e411e7 | ||
|
|
355bb5c4f6 | ||
|
|
7aa00b41da | ||
|
|
f3a3224745 | ||
|
|
e6bf586556 | ||
|
|
263d2f23c8 | ||
|
|
61ef472e71 | ||
|
|
4ef0cb76e6 | ||
|
|
891a58eff8 | ||
|
|
318e6b07b7 | ||
|
|
ba5447bbd9 | ||
|
|
c3a830bc38 | ||
|
|
b56400b5f9 | ||
|
|
494ce39de5 | ||
|
|
f97d57f859 | ||
|
|
a60f7e1858 | ||
|
|
8fd0612069 | ||
|
|
3f5838843d | ||
|
|
1d62d75150 | ||
|
|
7e3fc2d021 | ||
|
|
b45ceb9427 | ||
|
|
ed96b2ff65 | ||
|
|
e54fe9c31e | ||
|
|
904414aa7d | ||
|
|
ad165a5a00 | ||
|
|
8796aa9d85 | ||
|
|
225f3d7b0c | ||
|
|
0ae6c288ea | ||
|
|
b25c106fbf | ||
|
|
22118a3e8e | ||
|
|
8894da68d7 | ||
|
|
cdd656bd48 | ||
|
|
03c0b8f355 | ||
|
|
0934f97627 | ||
|
|
c10da25ce4 | ||
|
|
9c91c36e7e | ||
|
|
8ab574b7de | ||
|
|
e0ba6e77d1 | ||
|
|
9c289db91d | ||
|
|
b1a5b79d67 | ||
|
|
156fbe9912 | ||
|
|
1f4daf519d | ||
|
|
638d55983f | ||
|
|
c6d9822f26 | ||
|
|
fe83800ed5 | ||
|
|
567f5edaf3 | ||
|
|
0a6a047fd9 | ||
|
|
534f00dfd3 | ||
|
|
c5b2495291 | ||
|
|
7dd72dee58 | ||
|
|
d6b63fbc87 | ||
|
|
fb905ae911 | ||
|
|
841ea1716d | ||
|
|
7975b07b22 | ||
|
|
a1a27636e1 | ||
|
|
33a5f2a801 | ||
|
|
fdf6ba9c9d | ||
|
|
f17ecf4f5c | ||
|
|
b3ac169f00 | ||
|
|
dfb0ce36bd | ||
|
|
dbd2497b82 | ||
|
|
f9bdad7ee6 | ||
|
|
1a7ad48633 | ||
|
|
32950368b3 | ||
|
|
a55453d89c | ||
|
|
8d2aef8cd9 | ||
|
|
e1c0d92a5e | ||
|
|
316611374e | ||
|
|
db0c8e93f0 | ||
|
|
5ce38c7b2a | ||
|
|
a23f5711af | ||
|
|
327170a8e8 | ||
|
|
216d82a3fd | ||
|
|
449b0d4056 | ||
|
|
646abb3c5c | ||
|
|
e2b9705770 | ||
|
|
cfec15d8d9 | ||
|
|
a57aea8e94 | ||
|
|
4a68f84282 | ||
|
|
301a99d91c | ||
|
|
6f9595542d | ||
|
|
5f8c4e9ab5 | ||
|
|
bb07d267aa | ||
|
|
5f4da3beb2 | ||
|
|
123b8ad8ea | ||
|
|
0c2c98f6e7 | ||
|
|
ff6aaaaeea | ||
|
|
0e6b2e331d | ||
|
|
f7714c539f | ||
|
|
bc50c7fa56 | ||
|
|
958c1dffe8 | ||
|
|
cfa65a2148 | ||
|
|
b6d14b1fd0 | ||
|
|
b5bc8f0d19 | ||
|
|
043b446b94 | ||
|
|
39db6abfce | ||
|
|
a3357121fb | ||
|
|
81d9e37e8f | ||
|
|
bb70aa8359 | ||
|
|
9258c4ea57 | ||
|
|
b0ec580828 | ||
|
|
03e6657e93 | ||
|
|
aeda9de044 | ||
|
|
35ecdf2142 | ||
|
|
1b54bc335d | ||
|
|
4844d54fda | ||
|
|
85cea7efea | ||
|
|
37174ac306 | ||
|
|
21a386a6d4 | ||
|
|
f9cbcfb046 | ||
|
|
2ce6f90522 | ||
|
|
caa80fb213 | ||
|
|
d9bd9b134d | ||
|
|
78980c8190 | ||
|
|
819e53fe92 | ||
|
|
6504dc7da2 | ||
|
|
9f0a9a362b | ||
|
|
cd89392ac9 | ||
|
|
db1d586674 | ||
|
|
7543ef966c | ||
|
|
67f131d786 | ||
|
|
2366fcfd80 | ||
|
|
7e56066847 | ||
|
|
63b0dc4c32 | ||
|
|
d3327b17a1 | ||
|
|
16366ce422 | ||
|
|
cc518797a8 | ||
|
|
5336d80196 | ||
|
|
85c81ff1e9 | ||
|
|
b75ced8a70 | ||
|
|
a0bd1fd16b | ||
|
|
f8396dbc4f | ||
|
|
fe027037d2 | ||
|
|
1369bd0c93 | ||
|
|
c2a944a686 | ||
|
|
e02ffe9513 | ||
|
|
a06899fdd0 | ||
|
|
422a5d7eb9 | ||
|
|
5fb311fb60 | ||
|
|
1a506fae4c | ||
|
|
3298c5088c | ||
|
|
951bf42714 | ||
|
|
7d5c4b4b9d | ||
|
|
6187a3f462 | ||
|
|
589797adef | ||
|
|
043ea1d525 | ||
|
|
a1eb4fc090 | ||
|
|
cde7b0ece7 | ||
|
|
96d5a14738 | ||
|
|
4944facd0a | ||
|
|
e11867128e | ||
|
|
ed1e976264 | ||
|
|
d9328c7668 | ||
|
|
2f4cc8a1cc | ||
|
|
3024821c32 | ||
|
|
5fc0be5b85 | ||
|
|
3152436020 | ||
|
|
66a0f457e9 | ||
|
|
de1ede49a7 | ||
|
|
3f1e72d983 | ||
|
|
3ea4802a53 | ||
|
|
eace7c749b | ||
|
|
ef2d678cbf | ||
|
|
1e60a572b1 | ||
|
|
e80cc9b49b | ||
|
|
f297589c76 | ||
|
|
89501d73c2 | ||
|
|
cb9fa7042f | ||
|
|
bb8897a2eb | ||
|
|
4d54fa27e3 | ||
|
|
7f93a191c7 | ||
|
|
0f0380b827 | ||
|
|
ae6d70ae19 | ||
|
|
b72ca300ed | ||
|
|
4892384168 | ||
|
|
64f875ff34 | ||
|
|
4d0f1dd0f8 | ||
|
|
1e6c17c54b | ||
|
|
6f56ad2e7f | ||
|
|
b374fee789 | ||
|
|
9abdbb0a71 | ||
|
|
d775549258 | ||
|
|
8a7e36ede8 | ||
|
|
7abc1cfa10 | ||
|
|
d82d6e3620 | ||
|
|
7fc8173253 | ||
|
|
466b4b0180 | ||
|
|
65ec1842ac | ||
|
|
ad358fb4e4 | ||
|
|
0814b70fe0 | ||
|
|
14df0db488 | ||
|
|
70937b59ba | ||
|
|
7fde8739a7 | ||
|
|
e50bd58146 | ||
|
|
3412d3a073 | ||
|
|
85335e9e14 | ||
|
|
977b8fb642 | ||
|
|
f05fbfc292 | ||
|
|
7ea75b01ba | ||
|
|
359d9bb833 | ||
|
|
dcb963e85b | ||
|
|
0d28ee0872 | ||
|
|
f4f60b6ac6 | ||
|
|
06d3bbd6e7 | ||
|
|
b50519587d | ||
|
|
4c890b4ba1 | ||
|
|
b8744f6d33 | ||
|
|
1f44a18c7a | ||
|
|
ba94976bf2 | ||
|
|
63402451cc | ||
|
|
65cc818572 | ||
|
|
ce53bb7c57 | ||
|
|
122fe05cee | ||
|
|
a5186f5a12 | ||
|
|
0d0ea352df | ||
|
|
206e81c3b7 | ||
|
|
24afe0f517 | ||
|
|
ec67ed9646 | ||
|
|
ce558ffb73 | ||
|
|
cbe7f83881 | ||
|
|
feb319027e | ||
|
|
69e77570b4 | ||
|
|
4159ffe6fd | ||
|
|
51e7044b0e | ||
|
|
dae13d57f0 | ||
|
|
3374d0778d | ||
|
|
04bb6d6f27 | ||
|
|
ef0ab7ba9d | ||
|
|
546c81624a | ||
|
|
13bd24ba79 | ||
|
|
eb25a85d90 | ||
|
|
504e719230 | ||
|
|
d443cb031c | ||
|
|
41afc9f3e5 | ||
|
|
4d62e0d0ef | ||
|
|
1d1e28a51f | ||
|
|
f870f3ac09 | ||
|
|
67dcfd4fc4 | ||
|
|
972945f80d | ||
|
|
62036eac6c | ||
|
|
c49fb30032 | ||
|
|
0cc7e59df0 | ||
|
|
26ec0464bb | ||
|
|
f0810ca58b | ||
|
|
35d6aa0a16 | ||
|
|
36dc6e0c11 | ||
|
|
791bd91d5c | ||
|
|
17314d6b61 | ||
|
|
544b17ec33 | ||
|
|
5e8581e42f | ||
|
|
63686e659e | ||
|
|
7af5790b71 | ||
|
|
8a91be7983 | ||
|
|
5bfe774420 | ||
|
|
e918615347 | ||
|
|
250da4226e | ||
|
|
17b4396a97 | ||
|
|
5adeada26d | ||
|
|
175ad4a1e3 | ||
|
|
95304ad7f6 | ||
|
|
02da9bbdc9 | ||
|
|
331fc48b24 | ||
|
|
8d2d838884 | ||
|
|
bc5bdf47d1 | ||
|
|
6b99150aac | ||
|
|
26bb452ba1 | ||
|
|
dbb8bdcaaa | ||
|
|
ecf4ae7a3a | ||
|
|
e518974913 | ||
|
|
a20b0308b8 | ||
|
|
3126e82ebd | ||
|
|
d8a298a310 | ||
|
|
9c42a2942a | ||
|
|
36286cc783 | ||
|
|
bc343414d8 | ||
|
|
7b0164e4e7 | ||
|
|
670946ef75 | ||
|
|
bdb9963c73 | ||
|
|
dc64aaa41b | ||
|
|
5366cf689e | ||
|
|
cca3a63e4a | ||
|
|
507c6e0e63 | ||
|
|
dfc1b846cc | ||
|
|
a169693817 | ||
|
|
db85d465c8 | ||
|
|
de8d5c96ab | ||
|
|
d44615753a | ||
|
|
e794c1ac63 | ||
|
|
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 |
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 | |
|
||||
|
||||
Generated
+138
-45
@@ -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"
|
||||
@@ -1296,6 +1304,12 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1752,9 +1766,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
@@ -1768,9 +1782,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
|
||||
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1780,28 +1794,28 @@ dependencies = [
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"skrifa 0.46.2",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"fearless_simd 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1812,9 +1826,9 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -1860,11 +1874,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 +1949,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1921,6 +1958,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 +3212,8 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3584,9 +3646,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
@@ -3777,6 +3839,16 @@ dependencies = [
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.46.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.43.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
@@ -4133,6 +4205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4433,11 +4506,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",
|
||||
@@ -4452,9 +4526,12 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
@@ -4464,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4475,7 +4552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4487,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4503,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4520,11 +4597,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",
|
||||
@@ -4536,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4553,7 +4631,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4568,7 +4646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4593,6 +4671,7 @@ dependencies = [
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-scriptcheck",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -4603,12 +4682,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",
|
||||
@@ -4623,10 +4703,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4637,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4649,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4668,7 +4749,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4682,7 +4763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4694,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4719,11 +4800,12 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4737,7 +4819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4760,9 +4842,16 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4786,7 +4875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4803,9 +4892,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",
|
||||
@@ -4813,6 +4903,7 @@ dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"windows-registry",
|
||||
@@ -4821,14 +4912,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",
|
||||
@@ -4843,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4851,12 +4943,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",
|
||||
@@ -4889,7 +4980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4912,7 +5003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"fearless_simd 0.4.1",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4927,7 +5018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"fearless_simd 0.4.1",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -5999,7 +6090,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6183,6 +6274,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6191,6 +6283,7 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+8
-3
@@ -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" }
|
||||
@@ -36,6 +37,7 @@ vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -59,13 +61,16 @@ wat = "1.228"
|
||||
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
|
||||
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
|
||||
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
rustix = { version = "1.1", default-features = false }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
|
||||
@@ -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
|
||||
@@ -217,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.
|
||||
@@ -226,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.
|
||||
|
||||
+40
-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-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; 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,38 @@ 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
|
||||
|
||||
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| 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-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
|
||||
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
|
||||
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
|
||||
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
|
||||
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
|
||||
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
|
||||
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
|
||||
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
|
||||
| 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: the light takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %) and can shine from behind the object, for every 3D type; effects saved with the earlier direction are lit exactly as before. |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Perspective is the documented 0°–160° angle for every 3D type, Revolve included; effects saved with the earlier 0–100 % strength look exactly as before. |
|
||||
| 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 |
|
||||
@@ -144,6 +168,11 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
|
||||
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
|
||||
|
||||
@@ -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,17 @@ 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. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
|
||||
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 +105,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 +220,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,17 +230,32 @@ 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}"))?;
|
||||
let opened = 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 r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
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 mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
out.insert("importWarnings".into(), w.clone());
|
||||
}
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -93,3 +99,19 @@ fn info_reports_why_an_eps_read_partly() {
|
||||
assert!(w.contains("`frobnicate`"), "{v}");
|
||||
assert_eq!(v["kinds"]["Path"], 1, "{v}");
|
||||
}
|
||||
|
||||
/// #1604: convert reports what reading the input left out (here a missing linked image) as
|
||||
/// importWarnings, next to the export's own warnings.
|
||||
#[test]
|
||||
fn convert_reports_import_warnings() {
|
||||
let svg = tmp("missing-link.svg");
|
||||
std::fs::write(
|
||||
&svg,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let png = tmp("missing-link.png");
|
||||
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
|
||||
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -402,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;
|
||||
};
|
||||
@@ -419,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;
|
||||
};
|
||||
@@ -428,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
}
|
||||
})),
|
||||
// A download that couldn't be started fails the save or export it is for (#1693).
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
|
||||
inbox: Some(inbox),
|
||||
recovery_store: Some(Arc::new(BrowserStore { locks })),
|
||||
// File → Print: the browser's print dialog.
|
||||
@@ -511,7 +514,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
// The download has started: a timer that can't be set leaves the URL allocated, no more.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -67,6 +71,16 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
|
||||
# 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.
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Parse automation roots before starting any desktop services. A malformed flag must not
|
||||
//! silently remove a root and leave the control channel unconfined.
|
||||
//! 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 {
|
||||
@@ -46,6 +48,50 @@ impl AutomationArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--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::*;
|
||||
@@ -87,4 +133,53 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Absolute transport budgets, independent of the time spent executing a request.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn deadline(budget: Duration) -> io::Result<Instant> {
|
||||
Instant::now().checked_add(budget).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "control transport deadline out of range"))
|
||||
}
|
||||
|
||||
fn remaining(until: Option<Instant>) -> io::Result<Duration> {
|
||||
until
|
||||
.and_then(|d| d.checked_duration_since(Instant::now()))
|
||||
.filter(|d| !d.is_zero())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "control transport deadline exceeded"))
|
||||
}
|
||||
|
||||
pub(super) struct DeadlineReader {
|
||||
stream: TcpStream,
|
||||
idle: Duration,
|
||||
line: Duration,
|
||||
until: Option<Instant>,
|
||||
waiting_for_first_byte: bool,
|
||||
}
|
||||
|
||||
impl DeadlineReader {
|
||||
pub(super) fn new(stream: TcpStream, idle: Duration, line: Duration) -> Self {
|
||||
Self { stream, idle, line, until: None, waiting_for_first_byte: false }
|
||||
}
|
||||
|
||||
pub(super) fn begin_line(&mut self, buffered: bool) -> io::Result<()> {
|
||||
// A partial next request already in BufReader must not get an idle budget too.
|
||||
self.waiting_for_first_byte = !buffered;
|
||||
self.until = Some(deadline(if buffered { self.line } else { self.idle })?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for DeadlineReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
self.stream.set_read_timeout(Some(remaining(self.until)?))?;
|
||||
let n = self.stream.read(buf)?;
|
||||
if n > 0 && self.waiting_for_first_byte {
|
||||
self.until = Some(deadline(self.line)?);
|
||||
self.waiting_for_first_byte = false;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct DeadlineWriter {
|
||||
stream: TcpStream,
|
||||
budget: Duration,
|
||||
until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl DeadlineWriter {
|
||||
pub(super) fn new(stream: TcpStream, budget: Duration) -> Self {
|
||||
Self { stream, budget, until: None }
|
||||
}
|
||||
|
||||
pub(super) fn begin_reply(&mut self) -> io::Result<()> {
|
||||
self.until = Some(deadline(self.budget)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for DeadlineWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
// Each partial write gets the remaining budget, never a fresh full timeout.
|
||||
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
|
||||
self.stream.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
|
||||
self.stream.flush()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -17,11 +17,45 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
#[path = "control_deadlines.rs"]
|
||||
mod deadlines;
|
||||
use deadlines::{DeadlineReader, DeadlineWriter};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
|
||||
const REPLY_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// How long a request waits for the app's reply.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is
|
||||
/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be
|
||||
/// stopped, and the reply says its outcome isn't known.
|
||||
fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value {
|
||||
let error = if ticket.give_up() {
|
||||
"timeout: the app did not start the request in time; it was withdrawn and will not run"
|
||||
} else {
|
||||
"timeout: the app is still running the request; it may yet complete"
|
||||
};
|
||||
json!({"ok": false, "error": error})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ConnectionTimeouts {
|
||||
idle: Duration,
|
||||
line: Duration,
|
||||
write: Duration,
|
||||
}
|
||||
|
||||
impl Default for ConnectionTimeouts {
|
||||
fn default() -> Self {
|
||||
Self { idle: Duration::from_secs(300), line: Duration::from_secs(30), write: Duration::from_secs(60) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
@@ -39,31 +73,78 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(slot) = acquire_slot(&open) else {
|
||||
let mut out = DeadlineWriter::new(stream, ConnectionTimeouts::default().write);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
if out.begin_reply().is_ok() {
|
||||
let _ = write_line(&mut out, &json!({"ok": false, "error": "too many control connections"}));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = spawn_connection(stream, tx.clone(), ctx.clone(), slot, ConnectionTimeouts::default()) {
|
||||
eprintln!("vectorcraft: control worker failed to start: {e}");
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
struct ConnectionSlot(Arc<AtomicUsize>);
|
||||
|
||||
fn acquire_slot(open: &Arc<AtomicUsize>) -> Option<ConnectionSlot> {
|
||||
let mut n = open.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if n >= MAX_CONNECTIONS {
|
||||
return None;
|
||||
}
|
||||
match open.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst) {
|
||||
Ok(_) => return Some(ConnectionSlot(Arc::clone(open))),
|
||||
Err(current) => n = current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConnectionSlot {
|
||||
fn drop(&mut self) {
|
||||
// Each successful acquisition creates exactly one non-cloneable guard.
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_connection(
|
||||
stream: TcpStream,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
slot: ConnectionSlot,
|
||||
timeouts: ConnectionTimeouts,
|
||||
) -> std::io::Result<std::thread::JoinHandle<()>> {
|
||||
std::thread::Builder::new().name("vectorcraft-control".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve_socket(stream, timeouts, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let ticket = req.ticket();
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
let read = DeadlineReader::new(read, timeouts.idle, timeouts.line);
|
||||
// Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining
|
||||
// budget costs one socket option call per piece, not one per JSON token.
|
||||
let out = DeadlineWriter::new(stream, timeouts.write);
|
||||
serve_lines_with(
|
||||
read,
|
||||
out,
|
||||
handle,
|
||||
|reader| {
|
||||
let buffered = !reader.buffer().is_empty();
|
||||
reader.get_mut().begin_line(buffered)
|
||||
},
|
||||
DeadlineWriter::begin_reply,
|
||||
);
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
@@ -109,7 +190,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
|
||||
// The line's deadline ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -127,15 +212,39 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Write `v` and a newline to `out` through a buffer of [`REPLY_CHUNK`] bytes, then flush `out`.
|
||||
fn write_line(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
|
||||
let mut out = BufWriter::with_capacity(REPLY_CHUNK, out);
|
||||
writeln!(out, "{v}")?;
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
#[cfg(test)]
|
||||
fn serve_lines<R: Read, W: Write>(read: R, out: W, handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
serve_lines_with(read, out, handle, |_| Ok(()), |_| Ok(()));
|
||||
}
|
||||
|
||||
fn serve_lines_with<R: Read, W: Write>(
|
||||
read: R,
|
||||
mut out: W,
|
||||
mut handle: impl FnMut(String, Value) -> Option<Value>,
|
||||
mut begin_line: impl FnMut(&mut BufReader<R>) -> std::io::Result<()>,
|
||||
mut begin_reply: impl FnMut(&mut W) -> std::io::Result<()>,
|
||||
) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
if begin_line(&mut reader).is_err() {
|
||||
return;
|
||||
}
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
// Nothing of a line arrived (the idle wait ran out, or the peer is gone): close without
|
||||
// a word. A line nobody asked for would be read as the reply to the client's next request.
|
||||
Err(_) if buf.is_empty() => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
@@ -145,7 +254,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
if begin_reply(&mut out).and_then(|()| write_line(&mut out, &r)).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
@@ -153,7 +262,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
},
|
||||
};
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
let _ = begin_reply(&mut out).and_then(|()| write_line(&mut out, &json!({"ok": false, "error": reject})));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +271,164 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn short_timeouts() -> ConnectionTimeouts {
|
||||
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
|
||||
}
|
||||
|
||||
fn socket_fixture(
|
||||
timeouts: ConnectionTimeouts,
|
||||
handle: impl FnMut(String, Value) -> Option<Value> + Send + 'static,
|
||||
) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (finished, done) = channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
|
||||
serve_socket(stream, timeouts, handle);
|
||||
finished.send(()).unwrap();
|
||||
});
|
||||
let client = TcpStream::connect(addr).unwrap();
|
||||
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
(client, done, worker)
|
||||
}
|
||||
|
||||
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
|
||||
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
|
||||
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
|
||||
let _ = client.shutdown(std::net::Shutdown::Both);
|
||||
drop(client);
|
||||
worker.join().unwrap();
|
||||
assert!(stopped, "the server retained a stalled connection beyond its transport deadline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_control_socket_releases_its_worker() {
|
||||
let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
/// An idle connection is closed without a line: a client that sends its next request late
|
||||
/// must not find an error waiting where its reply should be.
|
||||
#[test]
|
||||
fn idle_expiry_closes_without_writing_a_line() {
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
|
||||
let mut got = Vec::new();
|
||||
client.read_to_end(&mut got).unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection");
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
/// A line that stalls is refused in words a client's author can act on, not the system's.
|
||||
#[test]
|
||||
fn stalled_control_line_is_refused_with_the_reason() {
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
|
||||
client.write_all(b"{\"method\":").unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&line).unwrap(),
|
||||
json!({"ok": false, "error": "request line not completed in time; closing the connection"})
|
||||
);
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_control_line_releases_its_worker() {
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
|
||||
client.write_all(b"{\"method\":").unwrap();
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trickled_control_line_has_an_absolute_deadline() {
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
|
||||
client.write_all(b"{").unwrap();
|
||||
let mut writer = client.try_clone().unwrap();
|
||||
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let writing = Arc::clone(&stop);
|
||||
let trickle = std::thread::spawn(move || {
|
||||
while !writing.load(Ordering::SeqCst) {
|
||||
if writer.write_all(b" ").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
|
||||
let _ = client.shutdown(std::net::Shutdown::Both);
|
||||
trickle.join().unwrap();
|
||||
worker.join().unwrap();
|
||||
assert!(stopped, "trickled bytes restarted the line's deadline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonreading_control_peer_cannot_retain_the_writer() {
|
||||
let payload = "x".repeat(2 * 1024 * 1024);
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload})));
|
||||
client.write_all(b"{\"method\":\"large\"}\n").unwrap();
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_control_requests_get_fresh_transport_deadlines() {
|
||||
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
|
||||
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
|
||||
// Application processing is not transport time; begin the reply budget afterwards.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
Some(json!({"ok": true}))
|
||||
});
|
||||
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||
for id in [1, 2] {
|
||||
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
|
||||
}
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
done.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_partial_control_line_also_expires() {
|
||||
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| Some(json!({"ok": true})));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], 1);
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_worker_returns_its_connection_slot() {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect();
|
||||
let slot = acquire_slot(&open).unwrap();
|
||||
assert!(acquire_slot(&open).is_none());
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let (tx, _rx) = channel();
|
||||
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
|
||||
// Clean up an old implementation that never returned its slot as well.
|
||||
let _ = client.shutdown(std::net::Shutdown::Both);
|
||||
worker.join().unwrap();
|
||||
assert!(released, "the deadline did not free capacity for the next client");
|
||||
let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot");
|
||||
drop(replacement);
|
||||
drop(held);
|
||||
assert_eq!(open.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
@@ -173,6 +440,50 @@ mod tests {
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
/// Counts the `write` calls made on it and keeps the bytes they write.
|
||||
#[derive(Default)]
|
||||
struct Writes {
|
||||
calls: usize,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Write for Writes {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.calls += 1;
|
||||
self.bytes.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200.
|
||||
fn inspect_reply(n: u64) -> Value {
|
||||
let paths: Vec<Value> = (0..n)
|
||||
.map(|k| {
|
||||
let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0);
|
||||
json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261",
|
||||
"id": k + 2, "kind": "Path", "locked": false, "name": "<Path>", "stroke": "None", "strokeWidth": 0.0, "visible": true})
|
||||
})
|
||||
.collect();
|
||||
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
|
||||
}
|
||||
|
||||
/// #1725: a request its caller gave up before the app took it never runs; one the app took
|
||||
/// first can't be withdrawn, and the timeout says so.
|
||||
#[test]
|
||||
fn a_timed_out_request_is_withdrawn_unless_it_started() {
|
||||
let (waiting, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||
assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run"));
|
||||
assert!(!waiting.claim(), "the app skips a withdrawn request");
|
||||
let (running, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||
assert!(running.claim());
|
||||
assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running"));
|
||||
assert!(!running.claim(), "a request is taken once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
@@ -183,6 +494,25 @@ mod tests {
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_are_written_in_few_large_writes() {
|
||||
for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] {
|
||||
let mut out = Writes::default();
|
||||
let mut once = Some(reply.clone());
|
||||
serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take());
|
||||
let mut want = reply;
|
||||
want["id"] = json!(1);
|
||||
let text = String::from_utf8(out.bytes).unwrap();
|
||||
// The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB.
|
||||
assert_eq!(text, format!("{want}\n"));
|
||||
assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len());
|
||||
}
|
||||
// A refusal is one write as well.
|
||||
let mut out = Writes::default();
|
||||
serve_lines(&b"hello\n"[..], &mut out, |_, _| None);
|
||||
assert_eq!(out.calls, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
@@ -263,6 +593,37 @@ mod tests {
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let reply = inspect_reply(10_000);
|
||||
let mut want = reply.clone();
|
||||
want["id"] = json!(1);
|
||||
let want = format!("{want}\n");
|
||||
std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(reply.clone()));
|
||||
});
|
||||
// The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does.
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
let mut read = BufReader::new(c.try_clone().unwrap());
|
||||
let mut lines = vec![];
|
||||
for _ in 0..10 {
|
||||
c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
|
||||
let mut line = String::new();
|
||||
read.read_line(&mut line).unwrap();
|
||||
lines.push(line);
|
||||
}
|
||||
tx.send(lines).ok();
|
||||
});
|
||||
let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s"));
|
||||
assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
|
||||
+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]
|
||||
|
||||
@@ -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
|
||||
@@ -23,6 +24,8 @@ compile_error!("the win7 target requires --no-default-features --features window
|
||||
|
||||
mod automation_args;
|
||||
mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
@@ -40,6 +43,7 @@ mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
@@ -57,6 +61,8 @@ struct App {
|
||||
graphics_lost: bool,
|
||||
/// Frames the UI has run (see [`end_before_teardown`]).
|
||||
frames: u64,
|
||||
/// Keeps the custom title bar's window borderless (Windows, #1758).
|
||||
window_frame: Option<window_frame::WindowFrameFn>,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -67,6 +73,11 @@ impl eframe::App for App {
|
||||
file_access::unconfined(|| self.logic_frame(ctx, frame));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// winit gives the window its caption back when it changes the window's flags (maximize,
|
||||
// restore, …): take it off again before the frame is laid out (#1758).
|
||||
if let Some(window_frame) = self.window_frame.as_mut() {
|
||||
window_frame();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.services.native_menu.is_some() {
|
||||
mac_menu::raw_input_hook(raw);
|
||||
@@ -82,6 +93,11 @@ impl eframe::App for App {
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Preferences are written when confirmed, not only at exit: a session ended any other way
|
||||
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
|
||||
if self.app.take_prefs_changed() {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
}
|
||||
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
@@ -303,10 +319,17 @@ dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
|
||||
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.title("Place"), |d, (name, exts)| d.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.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.folder(folder),
|
||||
None => d,
|
||||
@@ -461,6 +484,8 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(_) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
|
||||
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
@@ -476,6 +501,8 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
|
||||
/// The host's services; `ui` is woken when a file dialog answers.
|
||||
fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
|
||||
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())),
|
||||
@@ -485,10 +512,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
// 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"),
|
||||
wayland,
|
||||
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
|
||||
@@ -589,7 +621,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
fn main() -> std::process::ExitCode {
|
||||
// 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_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
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"));
|
||||
@@ -597,7 +629,7 @@ fn main() -> std::process::ExitCode {
|
||||
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) {
|
||||
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) => {
|
||||
@@ -606,7 +638,6 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -616,6 +647,17 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -670,6 +712,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")]
|
||||
@@ -710,13 +755,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
|
||||
};
|
||||
@@ -810,7 +870,9 @@ fn main() -> std::process::ExitCode {
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
// The content and the pointer stay aligned on Windows (#1758).
|
||||
let window_frame = window_frame::service(cc, custom_titlebar);
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -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,75 @@
|
||||
//! Keeps the custom title bar's undecorated window truly borderless on Windows (#1758).
|
||||
//!
|
||||
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
|
||||
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
|
||||
//! desktop compositor still reserves that frame for the window surface: the content is drawn
|
||||
//! offset by the caption and border while the pointer keeps the un-offset client coordinates, so
|
||||
//! it no longer points at what is drawn under it (rust-windowing/winit#2419; PhotoCraft fixed the
|
||||
//! same in storytold/photocraft#2401). [`service`] strips the two styles and has Windows
|
||||
//! recalculate the frame; winit puts them back whenever it changes the window flags (maximize,
|
||||
//! restore, …), so the returned guard runs every frame.
|
||||
|
||||
/// Run once per frame; a no-op once the window is borderless.
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(all(windows, feature = "wgpu"))]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` with the system title bar
|
||||
/// (the window is decorated) or when its handle can't be found.
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<super::WindowFrameFn> {
|
||||
if !custom_titlebar {
|
||||
return None;
|
||||
}
|
||||
let target = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(h) => h.hwnd.get(),
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
strip(&hwnd);
|
||||
Some(Box::new(move || strip(&hwnd)))
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle: `EnumWindows` hands out real `HWND`s, so
|
||||
/// this needs no `unsafe` to wrap a raw one.
|
||||
fn find_window(target: isize) -> Option<HWND> {
|
||||
let mut found = None;
|
||||
let _ = winsafe::EnumWindows(|hwnd| {
|
||||
if hwnd.ptr() as isize == target {
|
||||
found = Some(hwnd);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION` (which includes `WS_BORDER`) and has Windows recalculate the frame;
|
||||
/// the sizing border stays, so edge resizing and snapping keep working.
|
||||
fn strip(hwnd: &HWND) {
|
||||
let style = hwnd.style();
|
||||
let without = style & !(co::WS::CAPTION | co::WS::BORDER);
|
||||
if without.raw() == style.raw() {
|
||||
return;
|
||||
}
|
||||
hwnd.set_style(without);
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(windows, feature = "wgpu")))]
|
||||
mod platform {
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<super::WindowFrameFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ 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())
|
||||
@@ -68,3 +73,24 @@ fn explicit_dash_folder_does_not_consume_version() {
|
||||
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 |
@@ -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
|
||||
|
||||
@@ -73,3 +69,32 @@ 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
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
@@ -17,7 +17,7 @@ synth = []
|
||||
crc32fast = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
ruzstd = "0.9.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -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).
|
||||
|
||||
@@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
const MAX_NODES: usize = 500_000;
|
||||
|
||||
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
|
||||
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
||||
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
read_within(bytes, limits, MAX_LAYER_PIXELS)
|
||||
}
|
||||
|
||||
/// [`read`], its pixel layers holding at most `pixels` pixels together.
|
||||
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive).document()
|
||||
let mut reader = Reader::new(&s, &mut archive);
|
||||
reader.pixels_left = pixels;
|
||||
reader.document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
@@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels_left: u64,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
|
||||
@@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
let pixels = u64::from(w) * u64::from(h);
|
||||
if pixels > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
return Ok(None);
|
||||
}
|
||||
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
|
||||
let Some(left) = r.pixels_left.checked_sub(pixels) else {
|
||||
r.warn("pixel layers beyond the pixels one document may hold");
|
||||
return Ok(None);
|
||||
};
|
||||
r.pixels_left = left;
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
@@ -404,6 +411,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
|
||||
/// of 0xFF). Written by hand for the reader, like [`fixture`].
|
||||
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
|
||||
let layer = |i: u32| {
|
||||
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
|
||||
for c in 1..=4 {
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
|
||||
}
|
||||
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
|
||||
};
|
||||
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
|
||||
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
|
||||
/// no data for them: the layers past the allowance are left out with a note.
|
||||
#[test]
|
||||
fn pixel_layers_share_one_allowance_for_the_whole_document() {
|
||||
let file = flat_layers(5, 16);
|
||||
let layers = |pixels: u64| {
|
||||
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
|
||||
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
|
||||
(n, doc.warnings)
|
||||
};
|
||||
assert_eq!(layers(5 * 256), (5, vec![]));
|
||||
let (kept, warnings) = layers(3 * 256 + 255);
|
||||
assert_eq!(kept, 3);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
|
||||
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
|
||||
let bytes = png();
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
+89
-5
@@ -16,6 +16,7 @@ mod defaults;
|
||||
pub mod track;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 {
|
||||
(0.02 * scale).clamp(0.01, 0.25)
|
||||
}
|
||||
|
||||
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
|
||||
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
|
||||
/// caller's job (`StrokeLayer::opacity`).
|
||||
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
|
||||
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
|
||||
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
|
||||
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
|
||||
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
|
||||
let mut out = match &brush.kind {
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
@@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
|
||||
for n in &mut out {
|
||||
colorize(n, colorization, &stroke.paint);
|
||||
}
|
||||
leave_out_brushed(doc, &mut out, &mut Known::new());
|
||||
out
|
||||
}
|
||||
|
||||
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
|
||||
type Named<'a> = (bool, &'a str);
|
||||
|
||||
/// What [`draws_brushed`] found of each symbol and pattern so far.
|
||||
type Known = HashMap<(bool, String), bool>;
|
||||
|
||||
/// The symbol an instance draws and the patterns that paint `n` (not its children).
|
||||
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
|
||||
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
|
||||
_ => None,
|
||||
});
|
||||
symbol.into_iter().chain(patterns)
|
||||
}
|
||||
|
||||
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
|
||||
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
|
||||
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
|
||||
let key = (def.0, def.1.to_string());
|
||||
if let Some(&answer) = known.get(&key) {
|
||||
return answer;
|
||||
}
|
||||
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
|
||||
match pattern {
|
||||
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
|
||||
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
|
||||
}
|
||||
};
|
||||
// Without recursion, each one looked at once: they may use one another without end.
|
||||
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
|
||||
let mut answer = false;
|
||||
while let Some(n) = stack.pop() {
|
||||
if has_brush(n) {
|
||||
answer = true;
|
||||
break;
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
|
||||
for other in named(n) {
|
||||
if seen.insert((other.0, other.1.to_string())) {
|
||||
stack.extend(art(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
known.insert(key, answer);
|
||||
answer
|
||||
}
|
||||
|
||||
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
|
||||
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
|
||||
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
|
||||
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
|
||||
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
|
||||
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
|
||||
if let Paint::Pattern { pattern, .. } = paint
|
||||
&& draws_brushed(doc, (true, pattern.as_str()), known)
|
||||
{
|
||||
*paint = Paint::None;
|
||||
}
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
|
||||
for c in children.iter_mut() {
|
||||
walk(doc, Arc::make_mut(c), known);
|
||||
}
|
||||
}
|
||||
if n.extras().mask.is_some()
|
||||
&& let Some(mask) = n.extras_mut().mask.as_mut()
|
||||
{
|
||||
walk(doc, Arc::make_mut(&mut mask.art), known);
|
||||
}
|
||||
}
|
||||
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
|
||||
for n in nodes.iter_mut() {
|
||||
walk(doc, n, known);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
|
||||
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
|
||||
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
@@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
for mut p in stroke_pieces(b, &bp, st) {
|
||||
for mut p in stroke_pieces(doc, b, &bp, st) {
|
||||
p.opacity *= st.opacity;
|
||||
if st.blend != vectorcraft_color::BlendMode::Normal {
|
||||
p.blend = st.blend;
|
||||
|
||||
+91
-39
@@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec<Point>) {
|
||||
|
||||
#[test]
|
||||
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
|
||||
let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 1);
|
||||
let b = bounds(&out);
|
||||
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
|
||||
@@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
|
||||
|
||||
#[test]
|
||||
fn calligraphic_flat_nib_is_thin_along_its_angle() {
|
||||
let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
|
||||
let (a, b) = (5.0_f64, 1.0_f64);
|
||||
let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
|
||||
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
|
||||
// A vertical stroke with the same nib has the same width (45° is symmetric).
|
||||
let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
|
||||
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
|
||||
assert!(near(bounds(&v).width(), expect, 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calligraphic_scales_with_stroke_weight() {
|
||||
let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
|
||||
assert!(near(bounds(&out).height(), 6.0, 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_nib_is_direction_independent() {
|
||||
let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
|
||||
let b = bounds(&out);
|
||||
// Round caps: the bounds grow by the radius on every side.
|
||||
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
|
||||
@@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() {
|
||||
#[test]
|
||||
fn closed_calligraphic_makes_a_ring() {
|
||||
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
|
||||
let pd = out[0].path_data().unwrap();
|
||||
assert_eq!(pd.subpaths.len(), 2);
|
||||
let poly_area = |sp: &vectorcraft_geom::SubPath| {
|
||||
@@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node {
|
||||
|
||||
#[test]
|
||||
fn art_brush_stretches_to_path_length() {
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
|
||||
let b = bounds(&out);
|
||||
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
|
||||
assert!(near(b.height(), 4.0, 1e-6));
|
||||
// 3 pt stroke → 3× wider.
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
|
||||
assert!(near(bounds(&out).height(), 12.0, 1e-6));
|
||||
}
|
||||
|
||||
@@ -118,9 +120,9 @@ 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 out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let g = &out[0];
|
||||
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
|
||||
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
|
||||
@@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() {
|
||||
|
||||
#[test]
|
||||
fn art_brush_proportional_keeps_aspect() {
|
||||
let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 8.0, 1e-6));
|
||||
}
|
||||
|
||||
@@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() {
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let shaft = rect_art(80.0, 2.0);
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
|
||||
let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(
|
||||
&Document::new(1.0, 1.0),
|
||||
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
|
||||
&line(0.0, 0.0, 300.0, 0.0),
|
||||
&stroke(1.0),
|
||||
);
|
||||
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
|
||||
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
|
||||
}
|
||||
@@ -154,7 +162,7 @@ fn art_brush_follows_curves() {
|
||||
bp_open.push(*el);
|
||||
}
|
||||
}
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
let mut pts = vec![];
|
||||
all_points(&out[0], &mut pts);
|
||||
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
|
||||
@@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush {
|
||||
|
||||
#[test]
|
||||
fn scatter_count_and_spacing() {
|
||||
let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 10);
|
||||
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
|
||||
for (i, c) in centres.iter().enumerate() {
|
||||
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
|
||||
}
|
||||
let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 5);
|
||||
}
|
||||
|
||||
@@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() {
|
||||
}),
|
||||
};
|
||||
let p = line(0.0, 0.0, 300.0, 0.0);
|
||||
let a1 = stroke_pieces(&b, &p, &stroke(1.0));
|
||||
let a2 = stroke_pieces(&b, &p, &stroke(1.0));
|
||||
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
|
||||
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
|
||||
assert_eq!(a1, a2);
|
||||
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
|
||||
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
|
||||
@@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
|
||||
#[test]
|
||||
fn pattern_tile_count_by_fit() {
|
||||
let p = line(0.0, 0.0, 100.0, 0.0);
|
||||
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
|
||||
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
|
||||
let p2 = line(0.0, 0.0, 104.0, 0.0);
|
||||
let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
|
||||
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
|
||||
assert_eq!(st.len(), 10);
|
||||
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
|
||||
let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
|
||||
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(sp.len(), 10);
|
||||
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
|
||||
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
|
||||
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_places_corner_tiles() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
|
||||
let corners = out
|
||||
.iter()
|
||||
.filter(
|
||||
@@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() {
|
||||
#[test]
|
||||
fn bristle_makes_translucent_strands() {
|
||||
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
assert!(out.len() >= 5, "{}", out.len());
|
||||
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
|
||||
let bb = bounds(&out);
|
||||
@@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() {
|
||||
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
|
||||
a.colorization = Colorization::Tints;
|
||||
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let mut white = rect_art(1.0, 1.0);
|
||||
white.appearance.set_fill(Paint::solid(Color::WHITE));
|
||||
@@ -310,7 +318,7 @@ fn brushes_serde_round_trip() {
|
||||
fn every_default_brush_draws_something() {
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
for b in defaults() {
|
||||
let out = stroke_pieces(b, &bp, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
|
||||
assert!(!out.is_empty(), "{}", b.name);
|
||||
let bb = bounds(&out);
|
||||
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
|
||||
@@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
|
||||
fn pressure_maps_to_size_along_the_stroke() {
|
||||
let b = pressure_calli(10.0, 4.0);
|
||||
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
|
||||
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
|
||||
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
|
||||
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
|
||||
// The stroke weight scales it.
|
||||
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
|
||||
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
|
||||
// Kept within the valid range: 2 ± 4 never goes below nothing.
|
||||
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
|
||||
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
|
||||
}
|
||||
|
||||
@@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() {
|
||||
let horizontal = line(0.0, 0.0, 100.0, 0.0);
|
||||
let draw = |c: Calligraphic, p: f64| {
|
||||
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
|
||||
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
|
||||
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
|
||||
};
|
||||
use Variation::{Fixed, Pressure};
|
||||
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
|
||||
@@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() {
|
||||
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
|
||||
let wide = |p| {
|
||||
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
|
||||
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
|
||||
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
|
||||
};
|
||||
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
|
||||
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
|
||||
@@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() {
|
||||
..old.clone()
|
||||
};
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
|
||||
);
|
||||
// Fixed ignores the variation; Random still draws a different size per stroke.
|
||||
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
|
||||
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
|
||||
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
|
||||
// Recorded pressure changes nothing for a brush that doesn't use it.
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
|
||||
);
|
||||
// Modes are saved by name.
|
||||
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
|
||||
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
|
||||
@@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() {
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
|
||||
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
|
||||
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
|
||||
let mut pts = vec![];
|
||||
for n in &out {
|
||||
all_points(n, &mut pts);
|
||||
@@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
|
||||
#[test]
|
||||
fn scatter_values_follow_their_modes() {
|
||||
let bp = line(0.0, 0.0, 400.0, 0.0);
|
||||
let heights =
|
||||
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
|
||||
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
|
||||
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect()
|
||||
};
|
||||
// Fixed: every copy at 50 %, whatever the second value.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
|
||||
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
|
||||
@@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() {
|
||||
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
|
||||
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
|
||||
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
|
||||
);
|
||||
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
|
||||
}
|
||||
|
||||
@@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() {
|
||||
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
|
||||
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
|
||||
}
|
||||
|
||||
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
|
||||
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
|
||||
#[test]
|
||||
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
|
||||
use vectorcraft_doc::{PatternDef, Symbol};
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let mut brushed = rect_art(10.0, 4.0);
|
||||
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
|
||||
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
|
||||
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
|
||||
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
|
||||
}
|
||||
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let painted = |pattern: &str| {
|
||||
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
|
||||
};
|
||||
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
|
||||
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
|
||||
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(!out.is_empty());
|
||||
for piece in &out {
|
||||
let children = piece.children().unwrap();
|
||||
let symbols: Vec<&str> = children
|
||||
.iter()
|
||||
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(symbols, ["plain"]);
|
||||
let fills: Vec<Paint> =
|
||||
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
|
||||
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
|
||||
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
|
||||
warnings.extend(damaged);
|
||||
document.limit_depth_noting(&mut warnings);
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
|
||||
@@ -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![
|
||||
@@ -604,6 +651,27 @@ fn inserts_become_symbol_instances_with_their_transform() {
|
||||
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_insert_is_measured_clicked_and_marqueed_by_its_blocks_art() {
|
||||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
let [inst] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
// The line, 20 units up from the insertion point: a box with no width at x 100. The 20 × 20
|
||||
// square placed by the insert's transform would be a 60 × 40 box from x 70 to 130.
|
||||
let b = doc.bounds_of(&[inst.id], false).unwrap();
|
||||
assert!(close(b, Rect::new(100.0, h - 70.0, 100.0, h - 50.0), 1e-9), "{b:?}");
|
||||
// A click 25 pt right of the line, inside that square's box, selects nothing; one on the line
|
||||
// selects the insert.
|
||||
let hit = |x: f64| vectorcraft_doc::hit::hit_test(&doc, Point::new(x, h - 60.0), vectorcraft_doc::hit::HitOptions::default()).map(|h| h.leaf);
|
||||
assert_eq!(hit(125.0), None);
|
||||
assert_eq!(hit(100.0), Some(inst.id));
|
||||
// A marquee beside the line leaves it out; one across it takes it.
|
||||
let marquee = |r: Rect| vectorcraft_doc::hit::marquee(&doc, r, None, false);
|
||||
assert!(marquee(Rect::new(115.0, h - 65.0, 125.0, h - 55.0)).is_empty());
|
||||
assert_eq!(marquee(Rect::new(90.0, h - 62.0, 110.0, h - 58.0)), vec![inst.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_block_colours_make_a_symbol_per_look() {
|
||||
// The block's line takes the insert's colour: red and green make two symbols; a third red
|
||||
|
||||
+12
-6
@@ -8,7 +8,7 @@ use std::io::Cursor;
|
||||
|
||||
use kurbo::PathEl;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
@@ -158,6 +158,9 @@ struct LayerFlags {
|
||||
|
||||
pub(crate) struct Scene<'a> {
|
||||
doc: &'a Document,
|
||||
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
|
||||
/// crops to the region ([`SymbolBoxes::quick`]).
|
||||
symbols: SymbolBoxes<'a>,
|
||||
o: &'a DxfOptions,
|
||||
/// Document space → drawing units (y up, origin at the region's bottom-left).
|
||||
to_dxf: Affine,
|
||||
@@ -192,6 +195,7 @@ impl<'a> Scene<'a> {
|
||||
let r = o.region;
|
||||
Self {
|
||||
doc,
|
||||
symbols: doc.symbol_boxes().quick(),
|
||||
o,
|
||||
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
|
||||
k,
|
||||
@@ -271,7 +275,7 @@ impl<'a> Scene<'a> {
|
||||
return;
|
||||
}
|
||||
if self.o.crop && !layer {
|
||||
match n.visual_bounds() {
|
||||
match n.visual_bounds_with(&self.symbols) {
|
||||
Some(b) if !overlaps(b, self.o.region) => return,
|
||||
None if !n.is_container() => return,
|
||||
_ => {}
|
||||
@@ -349,7 +353,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));
|
||||
@@ -502,7 +506,7 @@ impl<'a> Scene<'a> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
||||
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
|
||||
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
|
||||
}
|
||||
|
||||
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
|
||||
@@ -652,8 +656,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);
|
||||
|
||||
+79
-1
@@ -125,6 +125,18 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
|
||||
assert_eq!(DxfVersion::from_id("2019"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_viewport_has_no_duplicate_group_codes() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let vport = table(&out, "VPORT").into_iter().find(|o| kind(o) == "VPORT").expect("VPORT table record");
|
||||
let codes: Vec<i32> = vport.iter().map(|pair| pair.0).collect();
|
||||
let unique: std::collections::HashSet<_> = codes.iter().collect();
|
||||
assert_eq!(unique.len(), codes.len(), "R12 VPORT record has duplicate group codes: {vport:?}");
|
||||
let z = vport.iter().find(|pair| pair.0 == 36).map(|pair| pair.1.trim().parse::<f64>().unwrap());
|
||||
assert_eq!(z, Some(1.0), "the view direction looks down the z axis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let d = shapes_doc();
|
||||
@@ -155,6 +167,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 +187,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 +421,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();
|
||||
@@ -387,3 +454,14 @@ fn crop_leaves_out_art_outside_the_region() {
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_keeps_an_instance_whose_art_reaches_the_region() {
|
||||
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the artboard,
|
||||
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
|
||||
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
|
||||
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
|
||||
let o = DxfOptions { crop: true, ..opts(&d) };
|
||||
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
|
||||
}
|
||||
@@ -441,7 +441,8 @@ impl Drawing {
|
||||
c.xy(13, 0.0, 0.0);
|
||||
c.xy(14, 1.0, 1.0);
|
||||
c.xy(15, 10.0, 10.0);
|
||||
c.xyz(16, 0.0, 0.0);
|
||||
// The view direction looks down the z axis: (0, 0, 1), each code once.
|
||||
c.xy(16, 0.0, 0.0);
|
||||
c.num(36, 1.0);
|
||||
c.xyz(17, 0.0, 0.0);
|
||||
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
|
||||
|
||||
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
|
||||
|
||||
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
|
||||
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
|
||||
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
|
||||
if let Some(date) = bytes.get_mut(24..36) {
|
||||
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
|
||||
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
|
||||
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
|
||||
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
|
||||
fn aligned(icc: Vec<u8>) -> Vec<u8> {
|
||||
const TABLE: usize = 128;
|
||||
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
|
||||
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
|
||||
let data_start = TABLE + 4 + 12 * n;
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let e = TABLE + 4 + 12 * i;
|
||||
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
|
||||
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
|
||||
return icc;
|
||||
}
|
||||
entries.push((off, size));
|
||||
}
|
||||
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
|
||||
return icc;
|
||||
}
|
||||
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| entries[i].0);
|
||||
for i in order {
|
||||
let (off, size) = entries[i];
|
||||
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
|
||||
Some((_, at)) => *at,
|
||||
None => {
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let at = out.len();
|
||||
out.extend_from_slice(&icc[off..off + size]);
|
||||
moved.push(((off, size), at));
|
||||
at
|
||||
}
|
||||
};
|
||||
let e = TABLE + 4 + 12 * i + 4;
|
||||
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
|
||||
}
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let len = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
|
||||
if let Some(id) = out.get_mut(84..100) {
|
||||
id.fill(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_tag(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
|
||||
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
|
||||
#[test]
|
||||
fn written_profiles_align_their_tags() {
|
||||
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
|
||||
let icc = super::icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
assert_eq!(be(0), icc.len(), "{name}: header size");
|
||||
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
|
||||
let n = be(128);
|
||||
for i in 0..n {
|
||||
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
|
||||
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
|
||||
assert!(off + size <= icc.len());
|
||||
}
|
||||
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
|
||||
let orig = moxcms::ColorProfile::new_srgb();
|
||||
if name == super::SRGB {
|
||||
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -24,8 +24,10 @@ pub struct Clip {
|
||||
}
|
||||
|
||||
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
|
||||
/// for each): deeper ones are left out.
|
||||
pub const MAX_NEST: usize = 128;
|
||||
/// for each): deeper ones are left out. As deep as a document's objects may nest
|
||||
/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are
|
||||
/// merged by [`Document::limit_depth`].
|
||||
pub const MAX_NEST: usize = crate::MAX_DEPTH - 2;
|
||||
|
||||
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
|
||||
pub fn deep_clips() -> String {
|
||||
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! How deep objects may nest.
|
||||
//!
|
||||
//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so
|
||||
//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper
|
||||
//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than
|
||||
//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own
|
||||
//! tile, draws without end): importers and the native reader merge deeper objects into the group
|
||||
//! around them and leave such instances and paints out
|
||||
//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and
|
||||
//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
|
||||
use crate::{Document, Node, NodeKind};
|
||||
|
||||
/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art
|
||||
/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts
|
||||
/// from 1 again.
|
||||
///
|
||||
/// A native file writes three levels of JSON for each level of objects, and its reader stops at
|
||||
/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that
|
||||
/// can be made can be saved and opened again.
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
|
||||
/// instance and a pattern's tile under the object it paints (and what those use, under them).
|
||||
/// Symbols a few objects deep nest in one another dozens deep within it.
|
||||
pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH;
|
||||
|
||||
/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`].
|
||||
pub fn merged_note() -> String {
|
||||
format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them")
|
||||
}
|
||||
|
||||
/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than
|
||||
/// [`MAX_DRAWN_DEPTH`].
|
||||
pub fn symbols_note() -> String {
|
||||
format!(
|
||||
"symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out"
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread
|
||||
/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For
|
||||
/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller.
|
||||
pub fn with_deep_stack<T: Send>(f: impl Fn() -> T + Sync) -> T {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threaded = std::thread::scope(|s| {
|
||||
let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?;
|
||||
Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
|
||||
});
|
||||
if let Some(done) = threaded {
|
||||
return done;
|
||||
}
|
||||
}
|
||||
f()
|
||||
}
|
||||
|
||||
/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its
|
||||
/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before
|
||||
/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with
|
||||
/// it, in the same place, is as it was.
|
||||
fn deeper(now: &[Arc<Node>], before: Option<&[Arc<Node>]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool {
|
||||
type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>;
|
||||
fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc<Node>], before: Option<&'a [Arc<Node>]>, depth: usize) {
|
||||
for (i, n) in now.iter().enumerate() {
|
||||
let was = before.and_then(|b| b.get(i));
|
||||
if !was.is_some_and(|w| Arc::ptr_eq(w, n)) {
|
||||
stack.push((n, was.map(|w| &**w), depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stack = vec![];
|
||||
push(&mut stack, now, before, depth);
|
||||
while let Some((n, was, d)) = stack.pop() {
|
||||
if d > MAX_DEPTH || over(n, d) {
|
||||
return true;
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1);
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art));
|
||||
push(&mut stack, std::slice::from_ref(&m.art), was, d + 1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// [`deeper`] for the nesting alone.
|
||||
fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
|
||||
deeper(now, None, depth, &|_, _| false)
|
||||
}
|
||||
|
||||
/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around
|
||||
/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and
|
||||
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
|
||||
/// whether anything changed. Recurses no deeper than the cap.
|
||||
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
|
||||
let mut changed = false;
|
||||
let mut i = 0;
|
||||
while let Some(n) = nodes.get_mut(i) {
|
||||
if !nested_deeper(std::slice::from_ref(n), depth) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let n = Arc::make_mut(n);
|
||||
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
|
||||
n.extras_mut().mask = None;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
limit(ch, depth + 1);
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
|
||||
let (visible, locked) = (n.visible, n.locked);
|
||||
let mut held = n.children_mut().map(std::mem::take).unwrap_or_default();
|
||||
if clip && !held.is_empty() {
|
||||
held.remove(0);
|
||||
}
|
||||
for c in &mut held {
|
||||
if (!visible && c.visible) || (locked && !c.locked) {
|
||||
let c = Arc::make_mut(c);
|
||||
c.visible &= visible;
|
||||
c.locked |= locked;
|
||||
}
|
||||
}
|
||||
// What it held takes its place, and is looked at in its turn.
|
||||
nodes.splice(i..=i, held);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
|
||||
/// instance its symbol's art and a pattern paint its tile's.
|
||||
type Def<'a> = (bool, &'a str);
|
||||
|
||||
/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses
|
||||
/// included (drawn under the object that uses them): `None` for one that uses itself, in its art
|
||||
/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`].
|
||||
type Heights<'a> = HashMap<Def<'a>, Option<usize>>;
|
||||
|
||||
/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it.
|
||||
fn uses(n: &Node) -> impl Iterator<Item = Def<'_>> {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let patterns = crate::swatches::node_paints(n).filter_map(|p| match p {
|
||||
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
|
||||
_ => None,
|
||||
});
|
||||
symbol.into_iter().chain(patterns)
|
||||
}
|
||||
|
||||
/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one
|
||||
/// another in a chain of any length. Their own art is within [`MAX_DEPTH`].
|
||||
fn heights(doc: &Document) -> Heights<'_> {
|
||||
// Each one's own depth, and what its art uses with the deepest use of each.
|
||||
type Own<'a> = (usize, Vec<(Def<'a>, usize)>);
|
||||
let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art))))
|
||||
.chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..])));
|
||||
let mut own: HashMap<Def, Own> = HashMap::new();
|
||||
for (def, art) in arts {
|
||||
let (mut deepest, mut used) = (0, HashMap::<Def, usize>::new());
|
||||
let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect();
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
deepest = deepest.max(d);
|
||||
for u in uses(n) {
|
||||
let at = used.entry(u).or_default();
|
||||
*at = (*at).max(d);
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
|
||||
}
|
||||
// (The first of two of one name is the one drawn.)
|
||||
own.entry(def).or_insert((deepest, used.into_iter().collect()));
|
||||
}
|
||||
// Depth first over what uses what. One met again while it is being worked out (`None` in its
|
||||
// entry) uses itself.
|
||||
let mut found: HashMap<Def, Option<Option<usize>>> = HashMap::new();
|
||||
let starts: Vec<Def> = own.keys().copied().collect();
|
||||
for start in starts {
|
||||
if found.contains_key(&start) {
|
||||
continue;
|
||||
}
|
||||
// (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn)
|
||||
let mut path: Vec<(Def, usize, Option<usize>)> = vec![(start, 0, own.get(&start).map(|o| o.0))];
|
||||
found.insert(start, None);
|
||||
while let Some(&(def, next, deepest)) = path.last() {
|
||||
let used = own.get(&def).map_or(&[][..], |o| &o.1);
|
||||
let Some(&(other, at)) = used.get(next) else {
|
||||
// Everything it uses is worked out.
|
||||
let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH);
|
||||
found.insert(def, Some(deepest));
|
||||
path.pop();
|
||||
if let Some((user, next, held)) = path.last_mut() {
|
||||
let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1);
|
||||
*held = held.zip(deepest).map(|(h, d)| h.max(at + d));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let known = found.get(&other).copied();
|
||||
if known.is_none() && own.contains_key(&other) {
|
||||
found.insert(other, None);
|
||||
}
|
||||
let Some((_, next, deepest)) = path.last_mut() else { break };
|
||||
*next += 1;
|
||||
match known {
|
||||
// Not in the document: it draws nothing.
|
||||
None if !own.contains_key(&other) => {}
|
||||
None => path.push((other, 0, own.get(&other).map(|o| o.0))),
|
||||
Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)),
|
||||
// It uses itself (it is on the way down here), or can't be drawn.
|
||||
Some(None | Some(None)) => *deepest = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
found.into_iter().map(|(def, h)| (def, h.flatten())).collect()
|
||||
}
|
||||
|
||||
/// Can't `used`, by an object `depth` deep, be drawn there?
|
||||
fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
|
||||
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
|
||||
}
|
||||
|
||||
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
|
||||
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
|
||||
/// within [`MAX_DEPTH`].
|
||||
fn strip(nodes: &mut Vec<Arc<Node>>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool {
|
||||
let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d));
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth)));
|
||||
let mut changed = nodes.len() != before;
|
||||
for n in nodes.iter_mut() {
|
||||
if !deeper(std::slice::from_ref(n), None, depth, &any) {
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let n = Arc::make_mut(n);
|
||||
if uses(n).any(|u| bad(u, depth)) {
|
||||
for p in crate::swatches::node_paints_mut(n) {
|
||||
if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) {
|
||||
*p = Paint::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An opacity mask whose art is such an instance goes with it.
|
||||
let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]);
|
||||
if let Some(art) = &mut mask
|
||||
&& strip(art, depth + 1, bad)
|
||||
{
|
||||
match art.pop() {
|
||||
Some(art) => {
|
||||
if let Some(m) = n.extras_mut().mask.as_mut() {
|
||||
m.art = art;
|
||||
}
|
||||
}
|
||||
None => n.extras_mut().mask = None,
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
strip(ch, depth + 1, bad);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`]
|
||||
/// (a symbol or pattern that uses itself is)? With `before` (this document before an edit,
|
||||
/// itself within the caps), only what the edit changed is looked at.
|
||||
pub fn too_deep(&self, before: Option<&Document>) -> bool {
|
||||
fn symbol(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
|
||||
d.symbols.get(i).map(|s| std::slice::from_ref(&s.art))
|
||||
}
|
||||
fn pattern(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
|
||||
d.patterns.get(i).map(|p| &p.art[..])
|
||||
}
|
||||
let nested = |_: &Node, _: usize| false;
|
||||
if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested)))
|
||||
|| (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if self.symbols.is_empty() && self.patterns.is_empty() {
|
||||
return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested);
|
||||
}
|
||||
let heights = heights(self);
|
||||
if heights.values().any(Option::is_none) {
|
||||
return true;
|
||||
}
|
||||
// Symbols and patterns draw as deep as before when none changed; else every use of them
|
||||
// is looked at again.
|
||||
let same = |x: &[Arc<Node>], y: &[Arc<Node>]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y));
|
||||
let before = before.filter(|b| {
|
||||
b.symbols.len() == self.symbols.len()
|
||||
&& b.patterns.len() == self.patterns.len()
|
||||
&& b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art))
|
||||
&& b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art))
|
||||
});
|
||||
let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d));
|
||||
deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over)
|
||||
}
|
||||
|
||||
/// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and
|
||||
/// patterns were left out.
|
||||
fn limit_depths(&mut self) -> (bool, bool) {
|
||||
let mut merged = limit(&mut self.layers, 1);
|
||||
for s in &mut self.symbols {
|
||||
// The art itself is 1 deep, so it stays the one object it is.
|
||||
let mut art = vec![s.art.clone()];
|
||||
if limit(&mut art, 1)
|
||||
&& let Some(one) = art.pop()
|
||||
{
|
||||
s.art = one;
|
||||
merged = true;
|
||||
}
|
||||
}
|
||||
for p in &mut self.patterns {
|
||||
merged |= limit(&mut p.art, 1);
|
||||
}
|
||||
if self.symbols.is_empty() && self.patterns.is_empty() {
|
||||
return (merged, false);
|
||||
}
|
||||
// The symbols and patterns that can't be drawn lose what their art uses: each is then as
|
||||
// deep as its own art, and what uses it can be drawn.
|
||||
let (symbols, patterns): (Vec<usize>, Vec<usize>) = {
|
||||
let heights = heights(self);
|
||||
let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none);
|
||||
(
|
||||
(0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(),
|
||||
(0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(),
|
||||
)
|
||||
};
|
||||
let mut left_out = !(symbols.is_empty() && patterns.is_empty());
|
||||
let all = |_: Def, _: usize| true;
|
||||
for i in symbols {
|
||||
let Some(s) = self.symbols.get_mut(i) else { continue };
|
||||
let mut art = vec![s.art.clone()];
|
||||
strip(&mut art, 1, &all);
|
||||
// (An instance alone was the whole art: an empty group stands in.)
|
||||
s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![])));
|
||||
}
|
||||
for i in patterns {
|
||||
if let Some(p) = self.patterns.get_mut(i) {
|
||||
strip(&mut p.art, 1, &all);
|
||||
}
|
||||
}
|
||||
// Then the uses drawn too deep where they are.
|
||||
let mut layers = self.layers.clone();
|
||||
left_out |= {
|
||||
let heights = heights(self);
|
||||
strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth))
|
||||
};
|
||||
self.layers = layers;
|
||||
(merged, left_out)
|
||||
}
|
||||
|
||||
/// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`]
|
||||
/// into the group around them, and leave out the uses of symbols and patterns that can't be
|
||||
/// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an
|
||||
/// importer does to what it read. Returns whether anything changed.
|
||||
pub fn limit_depth(&mut self) -> bool {
|
||||
let (merged, left_out) = self.limit_depths();
|
||||
merged || left_out
|
||||
}
|
||||
|
||||
/// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's
|
||||
/// `warnings` for what it did.
|
||||
pub fn limit_depth_noting(&mut self, warnings: &mut Vec<String>) {
|
||||
let (merged, left_out) = self.limit_depths();
|
||||
warnings.extend(merged.then(merged_note));
|
||||
warnings.extend(left_out.then(symbols_note));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{NodeId, OpacityMask};
|
||||
|
||||
/// One path in `levels` groups, each holding a second path beside the next group.
|
||||
fn nested(levels: usize) -> Arc<Node> {
|
||||
let mut id = 0;
|
||||
let mut next = || {
|
||||
id += 1;
|
||||
NodeId(id)
|
||||
};
|
||||
let path = |id| Arc::new(Node::path(id, Default::default(), Default::default()));
|
||||
let mut inner = path(next());
|
||||
for _ in 0..levels {
|
||||
inner = Arc::new(Node::group(next(), vec![inner, path(next())]));
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
fn doc(art: Arc<Node>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(art);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_at_the_cap_is_left_alone() {
|
||||
// The layer, the groups and the path in them: MAX_DEPTH deep.
|
||||
let mut d = doc(nested(MAX_DEPTH - 2));
|
||||
let before = d.clone();
|
||||
assert!(!d.too_deep(None));
|
||||
assert!(!d.limit_depth());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeper_objects_are_merged_into_the_group_around_them() {
|
||||
for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] {
|
||||
let mut d = doc(nested(levels));
|
||||
assert!(d.too_deep(None), "{levels}");
|
||||
assert!(d.limit_depth(), "{levels}");
|
||||
assert!(!d.too_deep(None), "{levels}");
|
||||
// Every path is kept, under the layer and the groups down to the cap.
|
||||
assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}");
|
||||
assert!(!d.limit_depth(), "nothing more to merge");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
|
||||
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
|
||||
inner.visible = false;
|
||||
let mut art = Arc::new(inner);
|
||||
for i in 0..MAX_DEPTH {
|
||||
art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art]));
|
||||
}
|
||||
let mut masked = Node::group(NodeId(8000), vec![]);
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true)));
|
||||
let mut d = doc(art);
|
||||
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(Arc::new(masked));
|
||||
}
|
||||
assert!(d.limit_depth());
|
||||
assert!(!d.too_deep(None));
|
||||
let mut leaves = vec![];
|
||||
d.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
leaves.push(n.visible);
|
||||
}
|
||||
});
|
||||
assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden");
|
||||
assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_is_checked_by_what_it_changed() {
|
||||
let before = doc(nested(MAX_DEPTH - 2));
|
||||
let mut wrapped = before.clone();
|
||||
if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
let all = std::mem::take(ch);
|
||||
ch.push(Arc::new(Node::group(NodeId(7000), all)));
|
||||
}
|
||||
assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper");
|
||||
let mut beside = before.clone();
|
||||
if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(nested(3));
|
||||
}
|
||||
assert!(!beside.too_deep(Some(&before)));
|
||||
assert!(!before.too_deep(Some(&before)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_and_patterns_are_limited_too() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
|
||||
assert!(d.too_deep(None));
|
||||
assert!(d.limit_depth());
|
||||
assert!(!d.too_deep(None));
|
||||
assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object");
|
||||
}
|
||||
|
||||
fn instance(id: u64, of: &str) -> Arc<Node> {
|
||||
Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() }))
|
||||
}
|
||||
|
||||
/// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`.
|
||||
fn symbols(count: usize, levels: usize, last: &str) -> Document {
|
||||
let mut d = doc(instance(1, "s0"));
|
||||
for i in 0..count {
|
||||
let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
|
||||
for _ in 0..levels {
|
||||
art = Arc::new(Node::group(NodeId(3), vec![art]));
|
||||
}
|
||||
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn instances(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. }));
|
||||
d.walk(&mut count);
|
||||
d.symbols.iter().for_each(|s| s.art.walk(&mut count));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_in_symbols_are_drawn_only_so_deep() {
|
||||
// Three objects deep each (two groups and the instance), under the instance on the layer.
|
||||
let fits = MAX_DRAWN_DEPTH / 3 - 1;
|
||||
let mut d = symbols(fits, 2, "none");
|
||||
assert!(!d.too_deep(None) && !d.limit_depth());
|
||||
let mut d = symbols(fits + 2, 2, "none");
|
||||
assert!(d.too_deep(None));
|
||||
let before = instances(&d);
|
||||
assert!(d.limit_depth() && !d.too_deep(None));
|
||||
assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go");
|
||||
// A chain of any length is measured, not walked by recursion.
|
||||
let mut d = symbols(100_000, 0, "none");
|
||||
assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_holding_itself_loses_the_instance() {
|
||||
for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] {
|
||||
let mut d = symbols(count, 1, last);
|
||||
assert!(d.too_deep(None), "{count} {last}");
|
||||
let mut warnings = vec![];
|
||||
d.limit_depth_noting(&mut warnings);
|
||||
assert_eq!(warnings, [symbols_note()]);
|
||||
assert!(!d.too_deep(None), "{count} {last}");
|
||||
assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group");
|
||||
}
|
||||
// An edit that makes one hold itself is seen, whatever else stayed as it was.
|
||||
let before = symbols(2, 1, "none");
|
||||
let mut after = before.clone();
|
||||
after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")]));
|
||||
assert!(!before.too_deep(None) && after.too_deep(Some(&before)));
|
||||
}
|
||||
|
||||
/// A path painted with the pattern named `with`.
|
||||
fn painted(id: u64, with: &str) -> Arc<Node> {
|
||||
let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() };
|
||||
Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0)))
|
||||
}
|
||||
|
||||
fn pattern_fills(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. })));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_painting_its_own_tile_loses_the_paint() {
|
||||
// One that paints its own tile; two that paint each other's; one in a symbol it holds.
|
||||
let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")]));
|
||||
let each_other = |d: &mut Document| {
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
|
||||
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")]));
|
||||
};
|
||||
let through_a_symbol = |d: &mut Document| {
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
|
||||
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
|
||||
};
|
||||
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
|
||||
let mut d = doc(painted(1, "p"));
|
||||
make(&mut d);
|
||||
assert!(d.too_deep(None));
|
||||
let mut warnings = vec![];
|
||||
d.limit_depth_noting(&mut warnings);
|
||||
assert_eq!(warnings, [symbols_note()]);
|
||||
assert!(!d.too_deep(None));
|
||||
assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern");
|
||||
}
|
||||
// A pattern whose tile is painted with another is fine.
|
||||
let mut d = doc(painted(1, "p"));
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
|
||||
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
|
||||
assert!(!d.too_deep(None) && !d.limit_depth());
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+350
-55
@@ -1,9 +1,11 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -31,6 +33,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,16 +72,95 @@ 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
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
|
||||
// while a group (including a clipping group) is selected as one object. Repeatedly
|
||||
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
|
||||
// Isolation and Edit Contents change where selection groups begin; keep the original
|
||||
// traversal for those cases rather than change their Select Behind semantics.
|
||||
if opt.scope.is_some() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
let mut tops = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let symbols = doc.symbol_boxes();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
// Malformed files may contain non-layer roots; let the general traversal handle them.
|
||||
if !layer.is_layer() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
|
||||
// An envelope in Edit Contents may expose objects *within* a top-level group.
|
||||
// The repeated traversal handles that mixed hierarchy precisely.
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
}
|
||||
tops
|
||||
}
|
||||
|
||||
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
|
||||
/// inspected once (only until its first hit). `false` asks the caller to use the general
|
||||
/// algorithm for an envelope whose contents are currently being edited. Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||||
let Some(children) = layer.children() else { return true };
|
||||
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return true;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
if !objects_at_layer(c, p, opt, symbols, tops, seen) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Editing an envelope selects its contents rather than the containing object. Even
|
||||
// when nested in a group, the old traversal may select both a member and the group.
|
||||
if c.is_container() {
|
||||
let mut editing = false;
|
||||
c.walk(&mut |n| editing |= edits_contents(n));
|
||||
if editing {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(c, p, opt, symbols).is_some(),
|
||||
};
|
||||
if hit && seen.insert(c.id) {
|
||||
tops.push(c.id);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The general selection order also handles isolation and nested Edit Contents.
|
||||
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
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;
|
||||
}
|
||||
@@ -84,36 +169,42 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
|
||||
tops
|
||||
}
|
||||
|
||||
/// [`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> {
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
let finish = |mut h: Hit| {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
|
||||
h
|
||||
};
|
||||
let symbols = doc.symbol_boxes();
|
||||
// Isolation mode: only what is inside the isolated container hits. The art around it is
|
||||
// locked, and clicks pass through it (#833).
|
||||
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, &symbols, 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, &symbols, &|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, &symbols, skip, &mut chain, editable_only) {
|
||||
return Some(finish(h));
|
||||
}
|
||||
chain.pop();
|
||||
@@ -136,7 +227,33 @@ 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> {
|
||||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||||
/// isn't shown, or when `p` is outside its reach.
|
||||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
|
||||
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && source_isolated(c)
|
||||
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
opt: HitOptions,
|
||||
symbols: &SymbolBoxes,
|
||||
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,36 +263,21 @@ 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() {
|
||||
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) {
|
||||
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))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
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 }, symbols, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, 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, symbols, 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)
|
||||
.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 }),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only)
|
||||
.or_else(|| hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
@@ -195,7 +297,7 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
|
||||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
@@ -225,8 +327,8 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||||
@@ -261,18 +363,23 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
|
||||
}
|
||||
|
||||
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
|
||||
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
|
||||
_ => hit_leaf(n, p, opt),
|
||||
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
|
||||
_ => hit_leaf(n, p, opt, symbols),
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
|
||||
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
|
||||
@@ -295,38 +402,44 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, r: Rect) -> bool {
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
children.iter().any(|c| c.visible && touches(c, r))
|
||||
children.iter().any(|c| c.visible && touches(c, s, r))
|
||||
}
|
||||
_ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())),
|
||||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||||
// line) is touched where the marquee crosses it.
|
||||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||||
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
|
||||
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn collect_leaves(n: &Node, r: Rect, out: &mut Vec<NodeId>) {
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
for c in children {
|
||||
if c.visible && !c.locked {
|
||||
collect_leaves(c, r, out);
|
||||
collect_leaves(c, s, r, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if touches(n, r) {
|
||||
if touches(n, s, r) {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
for t in tops {
|
||||
if !t.visible || t.locked {
|
||||
continue;
|
||||
}
|
||||
if leaves {
|
||||
collect_leaves(t, r, &mut out);
|
||||
} else if touches(t, r) {
|
||||
collect_leaves(t, &symbols, r, &mut out);
|
||||
} else if touches(t, &symbols, r) {
|
||||
out.push(t.id);
|
||||
}
|
||||
}
|
||||
@@ -463,7 +576,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))] {
|
||||
@@ -523,7 +636,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;
|
||||
@@ -594,6 +707,167 @@ world",
|
||||
assert!(at(90.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
|
||||
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
|
||||
/// the per-object walk.
|
||||
#[test]
|
||||
fn objects_at_large_overlapping_stack() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..256 {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids.reverse();
|
||||
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
|
||||
}
|
||||
|
||||
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
|
||||
/// invisibility, and path/outline-only settings.
|
||||
#[test]
|
||||
fn objects_at_one_pass_matches_repeated_hits() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let add = |d: &mut Document, parent, bounds| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
|
||||
id
|
||||
};
|
||||
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
|
||||
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
|
||||
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
|
||||
let clip = d.alloc_id();
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
|
||||
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
|
||||
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
|
||||
clipping.set_clips(true);
|
||||
d.insert(Some(layer), usize::MAX, clipping).unwrap();
|
||||
for (point, opt) in [
|
||||
(Point::new(25.0, 25.0), HitOptions::default()),
|
||||
(Point::new(3.0, 25.0), HitOptions::default()),
|
||||
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
|
||||
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
|
||||
(Point::new(100.0, 100.0), HitOptions::default()),
|
||||
] {
|
||||
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
|
||||
}
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
d.node_mut(group).unwrap().locked = true;
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
}
|
||||
|
||||
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
|
||||
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
|
||||
/// in the same order as the hit test repeated over what is left.
|
||||
#[test]
|
||||
fn objects_at_matches_repeated_hits_on_random_documents() {
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut next = move |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed % n
|
||||
};
|
||||
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
|
||||
for _ in 0..2 + next(4) {
|
||||
let id = d.alloc_id();
|
||||
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
|
||||
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
|
||||
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
|
||||
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
|
||||
4 | 5 => Node::group(id, Vec::new()),
|
||||
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
|
||||
7 => {
|
||||
let mut g = Node::group(id, Vec::new());
|
||||
g.set_clips(true);
|
||||
g
|
||||
}
|
||||
_ => Node::path(id, shapes::rectangle(rect), appearance),
|
||||
};
|
||||
n.visible = next(8) != 0;
|
||||
n.locked = next(8) == 0;
|
||||
if let NodeKind::Layer { preview, .. } = &mut n.kind {
|
||||
*preview = next(3) != 0;
|
||||
}
|
||||
let container = n.is_container();
|
||||
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
if container {
|
||||
fill(d, id, depth + 1, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
for round in 0..200 {
|
||||
let mut d = Document::new(120.0, 120.0);
|
||||
let layer = d.layers[0].id;
|
||||
fill(&mut d, layer, 0, &mut next);
|
||||
for _ in 0..12 {
|
||||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
|
||||
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
|
||||
/// one instance and 100 pt lower by an instance in a group.
|
||||
#[test]
|
||||
fn objects_at_finds_symbol_instances_by_their_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
|
||||
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
|
||||
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let instance =
|
||||
|id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
|
||||
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
|
||||
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
|
||||
let opt = HitOptions::default();
|
||||
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
|
||||
let p = Point::new(x, y);
|
||||
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
|
||||
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Contents can expose descendants of a top-level group: preserve the original
|
||||
/// selection order by falling back to the general hit traversal.
|
||||
#[test]
|
||||
fn objects_at_edit_contents_uses_general_order() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
|
||||
let (a, b) = (d.alloc_id(), d.alloc_id());
|
||||
let e = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Envelope {
|
||||
content: vec![inner(a), inner(b)],
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: true,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(layer), usize::MAX, e).unwrap();
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
@@ -606,4 +880,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")
|
||||
}
|
||||
}
|
||||
+442
-61
@@ -11,14 +11,19 @@ pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
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;
|
||||
@@ -26,6 +31,7 @@ pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::MAX_RASTER_ALLOC;
|
||||
pub mod placed_document;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
@@ -39,9 +45,12 @@ pub mod shaper;
|
||||
pub mod slices;
|
||||
pub mod style_libs;
|
||||
pub mod swatches;
|
||||
mod symbols;
|
||||
pub use symbols::SymbolBoxes;
|
||||
pub mod text;
|
||||
pub mod trace;
|
||||
pub mod variables;
|
||||
pub mod xml_id;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -57,6 +66,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 +81,19 @@ 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 depth::MAX_DEPTH;
|
||||
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 +101,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 +557,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 +617,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 +686,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 +699,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 +719,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 +729,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 +766,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))));
|
||||
@@ -738,7 +776,9 @@ impl Document {
|
||||
/// Allocate a fresh node id.
|
||||
pub fn alloc_id(&mut self) -> NodeId {
|
||||
let id = NodeId(self.next_id);
|
||||
self.next_id += 1;
|
||||
// Never reached from a counter [`Self::fix_next_id`] has seen (it stays under
|
||||
// [`MAX_NEXT_ID`]); saturating so that no counter can overflow.
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
pub fn peek_next_id(&self) -> u64 {
|
||||
@@ -746,36 +786,71 @@ impl Document {
|
||||
}
|
||||
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
|
||||
pub fn fix_next_id(&mut self) {
|
||||
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
|
||||
// counter up there: new ids start under them and never reach them, where counting on from
|
||||
// the largest would run out and hand out ids the document already uses.
|
||||
let mut max = 0;
|
||||
let mut see = |id: u64| {
|
||||
if id < MAX_NEXT_ID {
|
||||
max = max.max(id);
|
||||
}
|
||||
};
|
||||
for l in &self.layers {
|
||||
l.walk(&mut |n| max = max.max(n.id.0));
|
||||
l.walk(&mut |n| see(n.id.0));
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||||
self.slices.iter().for_each(|s| see(s.id.0));
|
||||
self.assets.iter().for_each(|a| see(a.id));
|
||||
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
|
||||
self.next_id = counter.max(max.saturating_add(1));
|
||||
}
|
||||
|
||||
/// 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 +870,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 +1041,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)
|
||||
@@ -947,30 +1223,36 @@ impl Document {
|
||||
_ => LAYER_COLORS[0].1,
|
||||
}
|
||||
}
|
||||
/// Union of the geometric bounds of `ids`.
|
||||
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
|
||||
/// instances measured by their art ([`Node::geometric_bounds_with`]).
|
||||
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
|
||||
ids.iter()
|
||||
.filter_map(|id| self.node(*id))
|
||||
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() }))
|
||||
let symbols = self.symbol_boxes();
|
||||
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
|
||||
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
|
||||
})
|
||||
}
|
||||
/// Bounds of all art.
|
||||
/// Visual bounds of all art, with symbol instances measured by their art.
|
||||
pub fn art_bounds(&self) -> Option<Rect> {
|
||||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds()))
|
||||
let symbols = self.symbol_boxes();
|
||||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
|
||||
}
|
||||
/// Artboard index containing point `p` (topmost = last).
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||||
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
|
||||
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
|
||||
/// Artboard).
|
||||
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
|
||||
/// hidden objects and layers stay put unless `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> {
|
||||
fn collect(n: &Node, rect: Rect, all: bool, out: &mut 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, s: &SymbolBoxes, 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() {
|
||||
collect(c, rect, all, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
collect(c, s, rect, all, out);
|
||||
} else if let Some(b) = c.geometric_bounds_with(s)
|
||||
// Inside it, edges included: art flush with the right or bottom edge is on it too
|
||||
// (`Rect::contains` leaves those out, #890).
|
||||
&& b.x0 >= rect.x0
|
||||
@@ -982,9 +1264,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
let symbols = self.symbol_boxes();
|
||||
let mut art = vec![];
|
||||
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
|
||||
collect(l, rect, locked_and_hidden, &mut art);
|
||||
collect(l, &symbols, rect, locked_and_hidden, &mut art);
|
||||
}
|
||||
art
|
||||
}
|
||||
@@ -1104,7 +1387,7 @@ impl Document {
|
||||
self.guides.extend(copies);
|
||||
}
|
||||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
free_id(self.artboards.iter().map(|a| a.id))
|
||||
}
|
||||
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
|
||||
self.patterns.iter().find(|p| p.name == name)
|
||||
@@ -1149,7 +1432,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
|
||||
@@ -1188,7 +1471,7 @@ impl Document {
|
||||
}
|
||||
/// An id no graphic style has.
|
||||
pub fn next_graphic_style_id(&self) -> u32 {
|
||||
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
|
||||
free_id(self.graphic_styles.iter().map(|g| g.id))
|
||||
}
|
||||
/// The id of graphic style `index`, assigning one if it has none yet.
|
||||
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
|
||||
@@ -1202,6 +1485,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> {
|
||||
@@ -1219,11 +1510,29 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
|
||||
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
|
||||
const MAX_NEXT_ID: u64 = 1 << 62;
|
||||
|
||||
/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there
|
||||
/// is (a damaged file), the smallest one free.
|
||||
fn free_id(used: impl Iterator<Item = u32>) -> u32 {
|
||||
let mut used: Vec<u32> = used.collect();
|
||||
if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) {
|
||||
return next;
|
||||
}
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
// Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them.
|
||||
let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count();
|
||||
u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
|
||||
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
|
||||
pub fn reserve_ids(&mut self, next: u64) {
|
||||
self.next_id = self.next_id.max(next);
|
||||
self.next_id = self.next_id.max(next.min(MAX_NEXT_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,6 +1580,37 @@ 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 art_on_artboard_measures_a_symbol_instance_by_its_art() {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
|
||||
let (layer, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
|
||||
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
|
||||
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
@@ -1317,6 +1657,45 @@ mod tests {
|
||||
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
|
||||
}
|
||||
|
||||
/// #1718: a file whose ids or counters are at their type's largest value still hands out ids
|
||||
/// nothing in it uses (and never 0), instead of overflowing.
|
||||
#[test]
|
||||
fn exhausted_ids_in_a_file_dont_run_out() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["next_id"] = serde_json::json!(u64::MAX);
|
||||
// Object `a` gets the largest id there is.
|
||||
fn renumber(v: &mut serde_json::Value, from: u64) {
|
||||
match v {
|
||||
serde_json::Value::Object(o) => {
|
||||
if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) {
|
||||
o.insert("id".into(), serde_json::json!(u64::MAX));
|
||||
}
|
||||
o.values_mut().for_each(|c| renumber(c, from));
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
renumber(&mut v["layers"], a.0);
|
||||
let mut d: Document = serde_json::from_value(v).unwrap();
|
||||
assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept");
|
||||
d.fix_next_id();
|
||||
let fresh: Vec<NodeId> = (0..3).map(|_| d.alloc_id()).collect();
|
||||
assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}");
|
||||
assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]);
|
||||
// A counter left at the end by other means stays there rather than wrapping to 0.
|
||||
d.next_id = u64::MAX;
|
||||
assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX));
|
||||
d.reserve_ids(u64::MAX);
|
||||
// Artboards and graphic styles: one past the largest, else the smallest id free.
|
||||
assert_eq!(free_id([1, 2, 7].into_iter()), 8);
|
||||
assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3);
|
||||
assert_eq!(free_id([u32::MAX].into_iter()), 1);
|
||||
d.artboards[0].id = u32::MAX;
|
||||
assert_eq!(d.next_artboard_id(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let (d, _, _) = doc_with_rects();
|
||||
@@ -1391,6 +1770,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();
|
||||
|
||||
+67
-23
@@ -318,8 +318,10 @@ impl WarpStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
|
||||
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
|
||||
/// proportions).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
use WarpStyle::*;
|
||||
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
|
||||
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
|
||||
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
|
||||
if b.abs() < 1e-6 {
|
||||
(x, y)
|
||||
} else {
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
|
||||
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
|
||||
// the edges are concentric arcs, and one that would pass the centre stops there
|
||||
// instead of folding over (#1578).
|
||||
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
|
||||
let sweep = b.abs() * PI;
|
||||
let r0 = 2.0 / sweep;
|
||||
let yy = if b > 0.0 { y } else { -y };
|
||||
let yy = if b > 0.0 { y } else { -y } * k;
|
||||
let a = x / r0;
|
||||
let r = r0 - yy;
|
||||
let r = (r0 - yy).max(0.0);
|
||||
let (s, c) = a.sin_cos();
|
||||
let (nx, ny) = (r * s, r0 - r * c);
|
||||
let (nx, ny) = (r * s, (r0 - r * c) / k);
|
||||
(nx, if b > 0.0 { ny } else { -ny })
|
||||
}
|
||||
}
|
||||
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
Box::new(move |u: f64, v: f64| {
|
||||
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
|
||||
let (x2, y2) = if horizontal {
|
||||
warp_point(st, b, dh, dv, x, y)
|
||||
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
|
||||
} else {
|
||||
let (a, b2) = warp_point(st, b, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
|
||||
(b2, a)
|
||||
};
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
@@ -1222,7 +1228,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 +1276,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 +1394,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 +1423,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 +1500,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 +1529,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 +1567,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 +1647,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 +1676,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
|
||||
}
|
||||
|
||||
@@ -1698,11 +1705,23 @@ pub fn subtree_has_live(n: &Node) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Largest stroke outset in a subtree (for visual bounds of envelopes).
|
||||
pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
|
||||
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
|
||||
/// outsets in the art of the symbols its instances show count too
|
||||
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
|
||||
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
|
||||
/// does.
|
||||
pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxes>) -> f64 {
|
||||
let mut o = 0.0f64;
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| o = o.max(c.appearance.outset()));
|
||||
n.walk(&mut |c| {
|
||||
o = o.max(c.appearance.outset());
|
||||
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
|
||||
&& let Some(art) = s.art(symbol)
|
||||
{
|
||||
let reach = if s.art_strokes() { art.reach } else { 0.0 };
|
||||
o = o.max(reach + s.brush_pad(symbol));
|
||||
}
|
||||
});
|
||||
}
|
||||
o
|
||||
}
|
||||
@@ -1710,6 +1729,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
#[test]
|
||||
fn arc_warp_is_round_in_real_proportions() {
|
||||
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
|
||||
let r0 = 2.0 / (b * PI);
|
||||
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
|
||||
for i in 0..=10 {
|
||||
let x = -1.0 + 0.2 * f64::from(i);
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
|
||||
let d = nx.hypot(ny * aspect - r0);
|
||||
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// At 100 % on a square the inner edge meets at the centre instead of folding over.
|
||||
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
|
||||
assert!(nx.abs() < 1e-12);
|
||||
// A square bent less than that is what it always was.
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
|
||||
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
|
||||
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
|
||||
}
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+746
-101
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
/// Angles closer than this (degrees) are the same.
|
||||
const EPS_DEG: f64 = 1e-6;
|
||||
@@ -86,11 +86,12 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
|
||||
impl Node {
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it.
|
||||
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
|
||||
/// without changing it, symbol instances measured by their art from `symbols`
|
||||
/// ([`Node::geometric_bounds_with`]).
|
||||
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
|
||||
// The group's own strokes paint around its members (as in `visual_bounds`).
|
||||
let o = if visual { self.appearance.outset() } else { 0.0 };
|
||||
b.map(|b| b.inflate(o, o))
|
||||
@@ -98,12 +99,12 @@ impl Node {
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first()?;
|
||||
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
|
||||
clip.bounds_in(a, visual && stroked)
|
||||
clip.bounds_in(symbols, a, visual && stroked)
|
||||
}
|
||||
_ => {
|
||||
let mut n = self.clone();
|
||||
n.transform(a, false);
|
||||
if visual { n.visual_bounds() } else { n.geometric_bounds() }
|
||||
if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,14 +118,16 @@ impl Document {
|
||||
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
|
||||
}
|
||||
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
|
||||
/// geometric bounds, or `visual` with their strokes.
|
||||
/// geometric bounds, or `visual` with their strokes, symbol instances measured by their art
|
||||
/// ([`Document::bounds_of`]).
|
||||
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
|
||||
let angle = self.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
|
||||
let symbols = self.symbol_boxes();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
}
|
||||
@@ -192,4 +195,19 @@ mod tests {
|
||||
d.move_node(id, Some(g), 0).unwrap();
|
||||
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
|
||||
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
|
||||
let c = Point::new(120.0, 105.0);
|
||||
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
|
||||
let b = d.oriented_bounds(&[id], false).unwrap();
|
||||
assert!(close(b.angle, 30.0));
|
||||
assert!(close(b.rect.width(), 40.0) && close(b.rect.height(), 10.0), "the bar, not the 20 × 20 square: {b:?}");
|
||||
assert!(b.center().distance(c) < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -499,8 +516,12 @@ impl RepeatSpec {
|
||||
}
|
||||
/// Geometric bounds of all instances.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
let b = self.source_bounds()?;
|
||||
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
|
||||
self.copies_bounds(self.source_bounds()?)
|
||||
}
|
||||
/// The bounds of all instances of a source measuring `source`, each placed by its transform
|
||||
/// ([`Self::transforms`]).
|
||||
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
|
||||
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
|
||||
}
|
||||
/// Apply a document transform (source + arrangement parameters).
|
||||
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -6,6 +6,22 @@ use std::sync::Arc;
|
||||
|
||||
use crate::ImageBlob;
|
||||
|
||||
/// The most memory the decoded RGBA pixels of one image may take: the allowance the importers
|
||||
/// and the renderer decode within (a 60 in sheet at 300 ppi is ~475 MB). The web app has 4 GB to
|
||||
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
|
||||
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
|
||||
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
|
||||
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
|
||||
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) * 4 > max {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A PNG blob of `png` bytes.
|
||||
pub fn png(png: Vec<u8>) -> Self {
|
||||
@@ -21,7 +37,7 @@ impl ImageBlob {
|
||||
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
|
||||
/// image can't be decoded or no pixel changed.
|
||||
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
|
||||
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
|
||||
let mut img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
|
||||
let mut changed = false;
|
||||
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
|
||||
@@ -67,7 +83,7 @@ impl ImageBlob {
|
||||
LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, img)) if *k == key => Some(img.clone()),
|
||||
_ => {
|
||||
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
|
||||
let img = Arc::new(rgba_within(&self.bytes, MAX_RASTER_ALLOC)?);
|
||||
*last = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
@@ -115,6 +131,18 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
|
||||
/// the decoder's own limit lets through is four times that as RGBA.
|
||||
#[test]
|
||||
fn pixels_past_the_allowance_are_not_decoded() {
|
||||
let grey = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
grey.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert!(rgba_within(&png, 31).is_none(), "8 grey bytes are 32 as RGBA");
|
||||
assert_eq!(rgba_within(&png, 32).map(|i| i.dimensions()), Some((4, 2)));
|
||||
assert!(rgba_within(b"not an image", u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_a_pixel_or_the_average_around_it() {
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
+65
-145
@@ -1,21 +1,28 @@
|
||||
//! 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>>,
|
||||
/// 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.
|
||||
@@ -55,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();
|
||||
@@ -62,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;
|
||||
@@ -78,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;
|
||||
}
|
||||
@@ -116,12 +125,45 @@ 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());
|
||||
// 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| doc.node(k)).is_some_and(|n| {
|
||||
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);
|
||||
@@ -131,7 +173,7 @@ impl Selection {
|
||||
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));
|
||||
@@ -142,9 +184,17 @@ impl Selection {
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
/// selected.
|
||||
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
||||
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
self.remove(o);
|
||||
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
|
||||
@@ -185,134 +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);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
+39
-10
@@ -109,10 +109,11 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
|
||||
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
|
||||
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
|
||||
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
|
||||
/// paint (and the paths to them) are copied. Returns the number of changed paints.
|
||||
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// `f(colour, link, tint)` may change them and returns true when it did
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
|
||||
let mut n = map_trees(&mut self.layers, f);
|
||||
for s in &mut self.symbols {
|
||||
@@ -152,18 +153,24 @@ impl Document {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
|
||||
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
|
||||
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
|
||||
/// gradient-mesh point as a solid paint.
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
n.walk(&mut |m| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).for_each(&mut *f);
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
}
|
||||
})
|
||||
};
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -219,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
|
||||
}
|
||||
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs.
|
||||
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let runs = match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
@@ -228,7 +235,7 @@ fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_mut_slice(),
|
||||
_ => &mut [],
|
||||
@@ -275,9 +282,31 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Opacity mask art lives outside the tree, but its colours are the object's too (#1735).
|
||||
if let Some(mask) = n.extras().mask.as_deref()
|
||||
&& let Some((new, k)) = map_node(&mask.art, f)
|
||||
{
|
||||
let m = out.get_or_insert_with(|| n.clone());
|
||||
if let Some(slot) = m.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
slot.art = Arc::new(new);
|
||||
count += k;
|
||||
}
|
||||
}
|
||||
out.map(|m| (m, count))
|
||||
}
|
||||
|
||||
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
|
||||
/// object, before its children).
|
||||
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(mask) = n.extras().mask.as_deref() {
|
||||
walk_with_masks(&mask.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_with_masks(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Symbol instances measured by their symbol's art: [`SymbolBoxes`] looks each symbol's art up in
|
||||
//! the document and measures it once, and an instance's bounds are its art's box placed by the
|
||||
//! instance's transform ([`Node::geometric_bounds_with`]).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
|
||||
|
||||
/// The box a symbol instance is measured by when its symbol's art has no box
|
||||
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
|
||||
/// that symbols made in the app keep their art in.
|
||||
pub(crate) const SYMBOL_SQUARE: Rect = Rect::new(-SYMBOL_HALF, -SYMBOL_HALF, SYMBOL_HALF, SYMBOL_HALF);
|
||||
|
||||
/// Instances of a symbol whose art holds symbols nested this many deep are measured by
|
||||
/// [`SYMBOL_SQUARE`] instead of their art.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Most effect reaches one set of boxes measures for transforms that place the art of symbols
|
||||
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
|
||||
const MAX_REACHES: usize = 4096;
|
||||
|
||||
/// A symbol's art measured in symbol space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct ArtBox {
|
||||
/// The art's geometric bounds.
|
||||
pub(crate) geometric: Rect,
|
||||
/// The art's visual bounds (its strokes included), never smaller than `geometric`.
|
||||
pub(crate) visual: Rect,
|
||||
/// The farthest any stroke in the art can paint from its path ([`crate::Appearance::outset`]).
|
||||
pub(crate) reach: f64,
|
||||
/// Whether the art, or the art of a symbol an instance in it shows, has a visible effect.
|
||||
pub(crate) effects: bool,
|
||||
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
|
||||
/// of a symbol an instance in it shows: `None` when no object there has one.
|
||||
pub(crate) brush: Option<f64>,
|
||||
}
|
||||
|
||||
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
|
||||
/// [`Node::visual_bounds_with`] and [`Node::reach_bounds_with`] measure symbol instances by. Each
|
||||
/// symbol's art is measured the first time an instance of it is measured; make a new one after the
|
||||
/// symbols change.
|
||||
pub struct SymbolBoxes<'a> {
|
||||
doc: &'a Document,
|
||||
/// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are
|
||||
/// measured by [`SYMBOL_SQUARE`]).
|
||||
measured: RefCell<HashMap<String, Option<ArtBox>>>,
|
||||
/// How deep symbols nest in the art of each symbol checked so far, by symbol name
|
||||
/// ([`Self::depth`]).
|
||||
depths: RefCell<HashMap<String, Option<usize>>>,
|
||||
/// Set while symbol art or a click's box is measured, and always in boxes made by
|
||||
/// [`Self::quick`]: instances are then measured by their art boxes alone
|
||||
/// ([`Self::placed_visual`] returns `None`).
|
||||
quick: Cell<bool>,
|
||||
/// Set in boxes made by [`Self::with_brush_art`], and cleared while symbol art is measured
|
||||
/// ([`Self::brush_pad`]).
|
||||
brush_art: Cell<bool>,
|
||||
/// Cleared in boxes made by [`Self::without_art_strokes`], and set while symbol art is
|
||||
/// measured ([`Self::art_strokes`]).
|
||||
art_strokes: Cell<bool>,
|
||||
/// The effect reaches measured so far, by symbol name and the bits of the linear part of the
|
||||
/// transform that places the art ([`Self::effects_reach`]).
|
||||
reaches: RefCell<HashMap<(String, [u64; 4]), f64>>,
|
||||
/// How many of them are of symbols whose art holds instances.
|
||||
holders: Cell<usize>,
|
||||
/// The farthest effect reach measured so far for each symbol.
|
||||
farthest: RefCell<HashMap<String, f64>>,
|
||||
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
|
||||
/// ([`Self::unplaced_reach`]).
|
||||
unplaced: RefCell<HashMap<String, f64>>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// [`SymbolBoxes`] for this document's symbols, none measured yet.
|
||||
pub fn symbol_boxes(&self) -> SymbolBoxes<'_> {
|
||||
SymbolBoxes {
|
||||
doc: self,
|
||||
measured: RefCell::default(),
|
||||
depths: RefCell::default(),
|
||||
quick: Cell::new(false),
|
||||
brush_art: Cell::new(false),
|
||||
art_strokes: Cell::new(true),
|
||||
reaches: RefCell::default(),
|
||||
holders: Cell::new(0),
|
||||
farthest: RefCell::default(),
|
||||
unplaced: RefCell::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> SymbolBoxes<'a> {
|
||||
/// The document whose symbols these are.
|
||||
pub fn document(&self) -> &'a Document {
|
||||
self.doc
|
||||
}
|
||||
|
||||
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
|
||||
/// art box alone. An instance that scales or turns stroked art then measures a little larger
|
||||
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it.
|
||||
pub fn quick(self) -> Self {
|
||||
self.quick.set(true);
|
||||
self
|
||||
}
|
||||
|
||||
/// Makes these boxes grow the visual box of each symbol instance whose art holds brushed
|
||||
/// strokes by as far as their brush art can paint past it ([`Self::brush_pad`]). The visual
|
||||
/// boxes of the groups, layers and live objects that hold such an instance grow with it.
|
||||
pub fn with_brush_art(self) -> Self {
|
||||
self.brush_art.set(true);
|
||||
self
|
||||
}
|
||||
|
||||
/// Makes these boxes measure the visual box of each symbol instance without the strokes in
|
||||
/// its art: the art's geometric box placed by the instance's transform, grown by the
|
||||
/// instance's own strokes. The strokes in the art keep their weight when the instance scales.
|
||||
/// The visual boxes of the groups, layers and live objects that hold an instance do not include
|
||||
/// those strokes either.
|
||||
pub fn without_art_strokes(self) -> Self {
|
||||
self.art_strokes.set(false);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether instances are measured with the strokes in their art: false in boxes made by
|
||||
/// [`Self::without_art_strokes`], except while symbol art is measured.
|
||||
pub(crate) fn art_strokes(&self) -> bool {
|
||||
self.art_strokes.get()
|
||||
}
|
||||
|
||||
/// The art box of the symbol named `name`, with the instances in the art measured by their own
|
||||
/// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite
|
||||
/// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`].
|
||||
pub(crate) fn art(&self, name: &str) -> Option<ArtBox> {
|
||||
if let Some(b) = self.measured.borrow().get(name) {
|
||||
return *b;
|
||||
}
|
||||
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
|
||||
self.measured.borrow_mut().insert(name.to_string(), b);
|
||||
b
|
||||
}
|
||||
|
||||
/// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`]
|
||||
/// is `None`, without running it.
|
||||
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
|
||||
self.depth(name)?;
|
||||
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
|
||||
Some(measure(&sym.art))
|
||||
}
|
||||
|
||||
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
|
||||
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
|
||||
/// the symbol are then measured by [`SYMBOL_SQUARE`].
|
||||
pub fn art_bounds(&self, name: &str) -> Option<Rect> {
|
||||
self.art(name).map(|a| a.geometric)
|
||||
}
|
||||
|
||||
/// Whether the art of the symbol named `name`, or of a symbol an instance in it shows, has a
|
||||
/// visible effect (raster or geometry, the object's own or a fill's or stroke's). False when
|
||||
/// [`Self::art`] is `None`.
|
||||
pub fn has_effects(&self, name: &str) -> bool {
|
||||
self.art(name).is_some_and(|a| a.effects)
|
||||
}
|
||||
|
||||
/// How far the effects in the art of the symbol named `name` paint past that art when a
|
||||
/// transform with the linear part `linear` places it, as `measure` measures it for a linear
|
||||
/// part. `measure` runs once for each symbol and linear part. For symbols whose art holds
|
||||
/// instances, it runs [`MAX_REACHES`] times at most for these boxes. After that, a linear part
|
||||
/// not measured yet takes the reach with no transform, times √2 and the larger of 1 and the
|
||||
/// linear part's Frobenius norm, or the farthest reach measured for the symbol when that is
|
||||
/// farther; `measure` runs once more for each of those symbols whose reach with no transform
|
||||
/// was not measured yet. The estimate holds for effects that paint a fixed distance past the
|
||||
/// art, or a distance in proportion to its size.
|
||||
pub fn effects_reach(&self, name: &str, linear: [f64; 4], measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
|
||||
let key = (name.to_string(), linear.map(f64::to_bits));
|
||||
if let Some(r) = self.reaches.borrow().get(&key) {
|
||||
return *r;
|
||||
}
|
||||
let holder = self.depth(name) != Some(0);
|
||||
if holder && self.holders.get() >= MAX_REACHES {
|
||||
let norm = linear.iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||
let estimate = self.unplaced_reach(name, measure) * std::f64::consts::SQRT_2 * norm.max(1.0);
|
||||
return self.farthest.borrow().get(name).map_or(estimate, |f| f.max(estimate));
|
||||
}
|
||||
if holder {
|
||||
self.holders.set(self.holders.get() + 1);
|
||||
}
|
||||
let r = measure(linear);
|
||||
self.reaches.borrow_mut().insert(key, r);
|
||||
let mut farthest = self.farthest.borrow_mut();
|
||||
let f = farthest.entry(name.to_string()).or_insert(r);
|
||||
*f = f.max(r);
|
||||
r
|
||||
}
|
||||
|
||||
/// [`Self::effects_reach`] with no transform, measured once for each symbol, also past
|
||||
/// [`MAX_REACHES`].
|
||||
fn unplaced_reach(&self, name: &str, measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
|
||||
const IDENTITY: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
if let Some(r) = self.reaches.borrow().get(&(name.to_string(), IDENTITY.map(f64::to_bits))) {
|
||||
return *r;
|
||||
}
|
||||
if let Some(r) = self.unplaced.borrow().get(name) {
|
||||
return *r;
|
||||
}
|
||||
let r = measure(IDENTITY);
|
||||
self.unplaced.borrow_mut().insert(name.to_string(), r);
|
||||
r
|
||||
}
|
||||
|
||||
/// The widest stroke of the objects with a visible brushed stroke in the art of the symbol
|
||||
/// named `name`, or in the art of a symbol an instance in it shows. `None` when no object there
|
||||
/// has one, and when [`Self::art`] is `None`.
|
||||
pub fn brush_width(&self, name: &str) -> Option<f64> {
|
||||
self.art(name)?.brush
|
||||
}
|
||||
|
||||
/// How far the visual box of an instance of the symbol named `name` grows in boxes made by
|
||||
/// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that
|
||||
/// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path
|
||||
/// (`brush_fx::cull_bounds` in vectorcraft-render). 0 in other boxes, while symbol art is
|
||||
/// measured, and when the art has no brushed stroke.
|
||||
pub(crate) fn brush_pad(&self, name: &str) -> f64 {
|
||||
if !self.brush_art.get() {
|
||||
return 0.0;
|
||||
}
|
||||
self.brush_width(name).map_or(0.0, |w| 16.0 * w.max(1.0) + 8.0)
|
||||
}
|
||||
|
||||
/// How deep symbols nest in the art of the symbol named `name`: 0 when the art holds no
|
||||
/// instance of a symbol in the document, 1 when the symbols its instances show hold none, and
|
||||
/// so on. Returns `None` when the document has no such symbol, when the symbol or a symbol
|
||||
/// nested in its art holds an instance of itself at any depth, and when symbols nest
|
||||
/// [`MAX_DEPTH`] deep: instances of the symbol are then measured by [`SYMBOL_SQUARE`]. The
|
||||
/// answer depends only on the document's symbols.
|
||||
fn depth(&self, name: &str) -> Option<usize> {
|
||||
if let Some(d) = self.depths.borrow().get(name) {
|
||||
return *d;
|
||||
}
|
||||
let mut learned = HashMap::new();
|
||||
let d = self.find_depth(name, &mut learned);
|
||||
let mut depths = self.depths.borrow_mut();
|
||||
depths.extend(learned);
|
||||
depths.insert(name.to_string(), d);
|
||||
d
|
||||
}
|
||||
|
||||
/// Finds [`Self::depth`] for the symbol named `name` by walking the symbols its art's instances
|
||||
/// show, depth first, without recursing. `learned` collects what the walk finds out about the
|
||||
/// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on
|
||||
/// the way down to a symbol that holds an instance of itself or nests too deep.
|
||||
fn find_depth(&self, name: &str, learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
|
||||
let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art);
|
||||
// The symbols on the way down from `name`: each with the symbols its art shows that are
|
||||
// still to be walked, and the deepest nesting found under it so far.
|
||||
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)];
|
||||
loop {
|
||||
let (_, todo, _) = path.last_mut()?;
|
||||
let Some(next) = todo.pop() else {
|
||||
// Everything under it is walked.
|
||||
let (done, _, d) = path.pop()?;
|
||||
if d >= MAX_DEPTH {
|
||||
learned.insert(done, None);
|
||||
return give_up(&path, learned);
|
||||
}
|
||||
learned.insert(done, Some(d));
|
||||
let Some((_, _, up)) = path.last_mut() else { return Some(d) };
|
||||
*up = (*up).max(d + 1);
|
||||
continue;
|
||||
};
|
||||
let known = learned.get(&next).copied().or_else(|| self.depths.borrow().get(&next).copied());
|
||||
match known {
|
||||
Some(Some(d)) => {
|
||||
if let Some((_, _, deepest)) = path.last_mut() {
|
||||
*deepest = (*deepest).max(d + 1);
|
||||
}
|
||||
}
|
||||
// It holds an instance of itself or nests too deep, and so does every symbol on
|
||||
// the way down to it.
|
||||
Some(None) => return give_up(&path, learned),
|
||||
// A symbol that holds an instance of itself.
|
||||
None if path.iter().any(|(n, ..)| *n == next) => return give_up(&path, learned),
|
||||
// Symbols nested MAX_DEPTH deep under `name`.
|
||||
None if path.len() >= MAX_DEPTH => return None,
|
||||
None => {
|
||||
if let Some(a) = art(&next) {
|
||||
let todo = shown(self.doc, a);
|
||||
path.push((next, todo, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
|
||||
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
|
||||
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
|
||||
/// click's box is measured.
|
||||
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
|
||||
if self.quick.get() {
|
||||
return None;
|
||||
}
|
||||
self.measure_art(name, |art| {
|
||||
let mut art = (**art).clone();
|
||||
art.transform(xf, false);
|
||||
self.in_art(|| art.visual(Some(self)))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Runs `f` with instances measured by their art boxes alone.
|
||||
pub(crate) fn quickly<R>(&self, f: impl FnOnce() -> R) -> R {
|
||||
let was = self.quick.replace(true);
|
||||
let r = f();
|
||||
self.quick.set(was);
|
||||
r
|
||||
}
|
||||
|
||||
/// Runs `f` as symbol art is measured: instances measured by their art boxes alone, with the
|
||||
/// strokes in their art, and without [`Self::brush_pad`], which the instance that shows the
|
||||
/// art adds once.
|
||||
fn in_art<R>(&self, f: impl FnOnce() -> R) -> R {
|
||||
let pad = self.brush_art.replace(false);
|
||||
let strokes = self.art_strokes.replace(true);
|
||||
let r = self.quickly(f);
|
||||
self.brush_art.set(pad);
|
||||
self.art_strokes.set(strokes);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every symbol on `path` in `learned` as one whose instances are measured by
|
||||
/// [`SYMBOL_SQUARE`]. Returns `None`.
|
||||
fn give_up(path: &[(String, Vec<String>, usize)], learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
|
||||
for (n, ..) in path {
|
||||
learned.insert(n.clone(), None);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The names of the symbols of `doc` that the instances in `art` show, each once.
|
||||
fn shown(doc: &Document, art: &Node) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
|
||||
&& seen.insert(symbol.as_str())
|
||||
&& doc.symbols.iter().any(|s| s.name == *symbol)
|
||||
{
|
||||
names.push(symbol.clone());
|
||||
}
|
||||
});
|
||||
names
|
||||
}
|
||||
|
||||
/// Measures symbol art `art`, with the instances in it measured by `symbols`.
|
||||
fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
|
||||
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
|
||||
let geometric = art.geometric(Some(symbols)).filter(finite)?;
|
||||
let visual = art.visual(Some(symbols)).filter(finite).map_or(geometric, |v| v.union(geometric));
|
||||
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
|
||||
let mut effects = false;
|
||||
let mut brush: Option<f64> = None;
|
||||
art.walk(&mut |n| {
|
||||
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
|
||||
effects |= own.any(|e| e.visible);
|
||||
let strokes = || n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None });
|
||||
if strokes().any(|s| s.visible && s.brush.is_some()) {
|
||||
brush = Some(strokes().fold(brush.unwrap_or(0.0), |w, s| w.max(s.width)));
|
||||
}
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
effects |= symbols.has_effects(symbol);
|
||||
if let Some(w) = symbols.brush_width(symbol) {
|
||||
brush = Some(brush.map_or(w, |b| b.max(w)));
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(ArtBox { geometric, visual, reach, effects, brush })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
use crate::{Appearance, Symbol};
|
||||
|
||||
/// A rectangle filled black, with a `stroke` pt black stroke (0: none).
|
||||
fn rect(d: &mut Document, r: Rect, stroke: f64) -> Node {
|
||||
let paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), paint, stroke))
|
||||
}
|
||||
|
||||
fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Node {
|
||||
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
|
||||
}
|
||||
|
||||
/// Symbol `name` holding `art` in a group.
|
||||
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
|
||||
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
|
||||
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_measures_as_its_art_placed_by_its_transform() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let bar = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 10.0), 2.0);
|
||||
symbol(&mut d, "Bar", vec![bar]);
|
||||
let moved = instance(&mut d, "Bar", Affine::translate((20.0, 30.0)));
|
||||
let scaled = instance(&mut d, "Bar", Affine::scale(3.0));
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(moved.geometric_bounds_with(&s), Some(Rect::new(20.0, 30.0, 40.0, 40.0)));
|
||||
// Without the document: the 20 × 20 square around the origin, moved.
|
||||
assert_eq!(moved.geometric_bounds(), Some(Rect::new(10.0, 20.0, 30.0, 40.0)));
|
||||
// The 2 pt stroke paints 1 pt around the bar.
|
||||
assert_eq!(moved.visual_bounds_with(&s), Some(Rect::new(19.0, 29.0, 41.0, 41.0)));
|
||||
assert_eq!(moved.reach_bounds_with(&s), moved.visual_bounds_with(&s));
|
||||
// Scaled 3 times: the geometry scales, the stroke keeps its weight.
|
||||
assert_eq!(scaled.geometric_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0)));
|
||||
assert_eq!(scaled.visual_bounds_with(&s), Some(Rect::new(-1.0, -1.0, 61.0, 31.0)));
|
||||
// A click's box: the geometric box grown by the farthest the stroke can reach (a mitered
|
||||
// corner up to its miter limit).
|
||||
let reach = d.symbols[0].art.children().unwrap()[0].appearance.outset();
|
||||
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let missing = instance(&mut d, "Nowhere", Affine::translate((50.0, 50.0)));
|
||||
assert_eq!(missing.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
|
||||
// A symbol whose art holds an instance of itself 100 pt to the right, and a symbol whose
|
||||
// art holds an instance of that one.
|
||||
let square = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
|
||||
let inner = instance(&mut d, "Loop", Affine::translate((100.0, 0.0)));
|
||||
symbol(&mut d, "Loop", vec![square.clone(), inner]);
|
||||
let looped = instance(&mut d, "Loop", Affine::IDENTITY);
|
||||
symbol(&mut d, "Holder", vec![square, looped]);
|
||||
let outer = instance(&mut d, "Loop", Affine::IDENTITY);
|
||||
let doubled = instance(&mut d, "Loop", Affine::scale(2.0));
|
||||
let holder = instance(&mut d, "Holder", Affine::translate((50.0, 50.0)));
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(outer.geometric_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
|
||||
assert_eq!(outer.visual_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
|
||||
assert_eq!(doubled.visual_bounds_with(&s), Some(Rect::new(-20.0, -20.0, 20.0, 20.0)));
|
||||
assert_eq!(holder.geometric_bounds_with(&s), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_measure_by_their_own_symbols_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
|
||||
symbol(&mut d, "Dot", vec![dot]);
|
||||
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
|
||||
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
|
||||
symbol(&mut d, "Pair", vec![right, below]);
|
||||
let pair = instance(&mut d, "Pair", Affine::translate((100.0, 100.0)));
|
||||
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
|
||||
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
|
||||
// the right: symbols nest 39 deep in Link 0's art and 31 deep in Link 8's.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
for i in 0..40 {
|
||||
let unit = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
|
||||
let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0)));
|
||||
symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] });
|
||||
}
|
||||
let link0 = instance(&mut d, "Link 0", Affine::IDENTITY);
|
||||
let link7 = instance(&mut d, "Link 7", Affine::IDENTITY);
|
||||
let link8 = instance(&mut d, "Link 8", Affine::IDENTITY);
|
||||
let (square, chain) = (Some(Rect::new(-10.0, -10.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 32.0, 1.0)));
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(link0.geometric_bounds_with(&s), square);
|
||||
assert_eq!(link8.geometric_bounds_with(&s), chain);
|
||||
assert_eq!(link7.geometric_bounds_with(&s), square);
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(link8.geometric_bounds_with(&s), chain);
|
||||
assert_eq!(link0.geometric_bounds_with(&s), square);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brushed_strokes_are_found_in_the_art_and_in_nested_symbols_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut line = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 3.0);
|
||||
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
|
||||
symbol(&mut d, "Brushed", vec![line]);
|
||||
let plain = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 5.0);
|
||||
let inner = instance(&mut d, "Brushed", Affine::IDENTITY);
|
||||
symbol(&mut d, "Holder", vec![plain.clone(), inner]);
|
||||
symbol(&mut d, "Plain", vec![plain]);
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(s.brush_width("Brushed"), Some(3.0));
|
||||
assert_eq!(s.brush_width("Holder"), Some(3.0), "the 5 pt stroke has no brush");
|
||||
assert_eq!(s.brush_width("Plain"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_with_brush_art_grow_instances_of_brushed_art_once() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
// A line from (0, 40) to (0, 60) with a 1 pt brushed stroke, and a symbol whose art holds an
|
||||
// instance of it.
|
||||
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
|
||||
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
|
||||
symbol(&mut d, "Nib", vec![line]);
|
||||
let inner = instance(&mut d, "Nib", Affine::IDENTITY);
|
||||
symbol(&mut d, "Holder", vec![inner]);
|
||||
let nib = instance(&mut d, "Nib", Affine::translate((-5.0, 0.0)));
|
||||
let holder = instance(&mut d, "Holder", Affine::translate((-5.0, 0.0)));
|
||||
let group = Node::group(d.alloc_id(), vec![Arc::new(nib.clone())]);
|
||||
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(crate::RepeatSpec::mirror(vec![Arc::new(nib.clone())], 90.0, 10.0)));
|
||||
let (plain, brushed) = (d.symbol_boxes(), d.symbol_boxes().with_brush_art());
|
||||
// 16 × 1 + 8 for the 1 pt stroke.
|
||||
for n in [&nib, &holder, &group, &repeat] {
|
||||
let b = n.visual_bounds_with(&plain).unwrap();
|
||||
assert_eq!(n.visual_bounds_with(&brushed), Some(b.inflate(24.0, 24.0)), "{}", n.kind_label());
|
||||
}
|
||||
assert_eq!(nib.geometric_bounds_with(&brushed), nib.geometric_bounds_with(&plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_reaches_are_measured_once_for_each_linear_part_and_up_to_a_limit() {
|
||||
// `Leaf` holds a square; `Holder` and `Other` each hold an instance of `Leaf`.
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
let square = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
|
||||
symbol(&mut d, "Leaf", vec![square]);
|
||||
for name in ["Holder", "Other"] {
|
||||
let leaf = instance(&mut d, "Leaf", Affine::IDENTITY);
|
||||
symbol(&mut d, name, vec![leaf]);
|
||||
}
|
||||
let s = d.symbol_boxes();
|
||||
let runs = &Cell::new(0);
|
||||
// A measure that finds `reach` for every linear part.
|
||||
let measure = |reach: f64| {
|
||||
move |_: [f64; 4]| {
|
||||
runs.set(runs.get() + 1);
|
||||
reach
|
||||
}
|
||||
};
|
||||
let unscaled = [1.0, 0.0, 0.0, 1.0];
|
||||
assert_eq!(s.effects_reach("Holder", unscaled, &measure(3.0)), 3.0);
|
||||
assert_eq!(s.effects_reach("Holder", unscaled, &measure(5.0)), 3.0, "measured once");
|
||||
assert_eq!(runs.get(), 1);
|
||||
for i in 1..MAX_REACHES {
|
||||
s.effects_reach("Holder", [1.0, 0.0, 0.0, 1.0 + i as f64], &measure(1.0));
|
||||
}
|
||||
assert_eq!(runs.get(), MAX_REACHES);
|
||||
// Past the limit: the reach with no transform, times √2 and the larger of 1 and the
|
||||
// Frobenius norm (√8 for a scale of 2, √2 with no transform, √0.5 for a scale of 0.5). That
|
||||
// reach is measured once more for a symbol not measured before.
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(near(s.effects_reach("Holder", [2.0, 0.0, 0.0, 2.0], &measure(9.0)), 12.0));
|
||||
assert!(near(s.effects_reach("Other", unscaled, &measure(9.0)), 18.0));
|
||||
assert!(near(s.effects_reach("Other", [0.5, 0.0, 0.0, 0.5], &measure(7.0)), 9.0 * std::f64::consts::SQRT_2));
|
||||
assert_eq!(runs.get(), MAX_REACHES + 1);
|
||||
// A symbol whose art holds no instance is measured past the limit too.
|
||||
assert_eq!(s.effects_reach("Leaf", [2.0, 0.0, 0.0, 2.0], &measure(7.0)), 7.0);
|
||||
assert_eq!(runs.get(), MAX_REACHES + 2);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -100,6 +100,47 @@ pub struct CharStyle {
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Object names ↔ XML IDs (Preferences › Units › Identify Objects By).
|
||||
//!
|
||||
//! Illustrator's public documentation: with XML ID selected, the Layers panel shows and edits
|
||||
//! names that conform to XML naming (letter, underscore or colon first; no spaces). Underscores
|
||||
//! stand for spaces and `_xHH_` / `_xHHHH_` escape other characters, as in `.ai` `AI10_ArtUID`
|
||||
//! values ([edit dynamic objects](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/edit-dynamic-objects.html)).
|
||||
|
||||
/// An object name from its XML id: `_` is a space and `_xHH_` / `_xHHHH_` a character; ids made
|
||||
/// unique end with `_` and 20 or more digits (stripped).
|
||||
pub fn to_object_name(id: &str) -> String {
|
||||
let id = strip_unique(id);
|
||||
let mut out = String::with_capacity(id.len());
|
||||
let mut rest = id;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if c == '_' {
|
||||
if let Some((ch, n)) = escaped(rest) {
|
||||
out.push(ch);
|
||||
rest = rest.get(n..).unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or_default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]).
|
||||
pub fn from_object_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
if c == ' ' {
|
||||
out.push('_');
|
||||
} else if c == '_' {
|
||||
out.push_str("_x5F_");
|
||||
} else if i == 0 && c.is_ascii_digit() {
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
}
|
||||
}
|
||||
if out.is_empty() || !starts_name(&out) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `id` is a usable XML ID for Identify Objects By › XML ID: non-empty, no whitespace,
|
||||
/// starts with a letter, `_` or `:`, and otherwise letters, digits, `.`, `-`, `_` or `:`.
|
||||
pub fn is_valid(id: &str) -> bool {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.chars().any(char::is_whitespace) {
|
||||
return false;
|
||||
}
|
||||
let mut chars = id.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first.is_alphabetic() || first == '_' || first == ':') {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn starts_name(id: &str) -> bool {
|
||||
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
|
||||
}
|
||||
|
||||
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
|
||||
fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
let end = body.find('_')?;
|
||||
let hex = body.get(..end)?;
|
||||
if hex.is_empty() || hex.len() > 6 {
|
||||
return None;
|
||||
}
|
||||
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
|
||||
Some((c, end + 3))
|
||||
}
|
||||
|
||||
/// `id` without the `_000…_` suffix that makes it unique.
|
||||
fn strip_unique(id: &str) -> &str {
|
||||
let Some(body) = id.strip_suffix('_') else {
|
||||
return id;
|
||||
};
|
||||
let Some(at) = body.rfind('_') else {
|
||||
return id;
|
||||
};
|
||||
let digits = body.get(at + 1..).unwrap_or_default();
|
||||
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_ai_style_ids() {
|
||||
for (name, id) in [
|
||||
("Layer 1", "Layer_1"),
|
||||
("My Rect #2", "My_Rect__x23_2"),
|
||||
("1abc (paren)", "_x31_abc__x28_paren_x29_"),
|
||||
("Ünï 日本", "Ünï_日本"),
|
||||
("a_b", "a_x5F_b"),
|
||||
] {
|
||||
assert_eq!(from_object_name(name), id, "encode {name}");
|
||||
assert_eq!(to_object_name(id), name, "decode {id}");
|
||||
}
|
||||
assert_eq!(to_object_name("S_00000092429200007574234970000005974767392385616558_"), "S");
|
||||
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
|
||||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||||
}
|
||||
}
|
||||
+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::*;
|
||||
@@ -107,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0;
|
||||
const MAX_MOVE: f64 = 1.0e7;
|
||||
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
|
||||
const MAX_REACH: f64 = 1.0e9;
|
||||
/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of
|
||||
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
|
||||
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
|
||||
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
|
||||
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
||||
@@ -114,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9;
|
||||
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
|
||||
/// on every redraw while each object varies its own way.
|
||||
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
|
||||
}
|
||||
|
||||
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
|
||||
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
|
||||
let random = flag(p, "random", false);
|
||||
// The share (0..1) of value `k` applied.
|
||||
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
|
||||
@@ -131,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
return path.transformed(m);
|
||||
}
|
||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > max {
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
break;
|
||||
@@ -194,7 +212,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 +228,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 {
|
||||
|
||||
+90
-11
@@ -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"];
|
||||
@@ -192,8 +200,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), 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,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
@@ -220,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.freeDistort",
|
||||
"Free Distort…",
|
||||
DT,
|
||||
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
|
||||
),
|
||||
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
|
||||
@@ -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…",
|
||||
@@ -606,21 +642,64 @@ pub fn default_params(id: &str) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept).
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
|
||||
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
|
||||
pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
Some(_) => Map::new(),
|
||||
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
|
||||
};
|
||||
if let Value::Object(p) = params {
|
||||
for (k, v) in p {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
// Upgraded before the defaults are under it: a default angle is no angle given.
|
||||
if let Value::Object(p) = upgrade_params(id, params) {
|
||||
out.extend(p);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
|
||||
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
|
||||
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
|
||||
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
|
||||
/// then) become the current ones that show exactly the same, unless those are given too, which win.
|
||||
pub fn upgrade_params(id: &str, params: &Value) -> Value {
|
||||
match params {
|
||||
Value::Object(m) => {
|
||||
let mut m = m.clone();
|
||||
upgrade_perspective(id, &mut m);
|
||||
upgrade_light(id, &mut m);
|
||||
Value::Object(m)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_light(id: &str, m: &mut Map<String, Value>) {
|
||||
if !is_three_d(id) {
|
||||
return;
|
||||
}
|
||||
let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation"));
|
||||
if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") {
|
||||
return;
|
||||
}
|
||||
let degrees = |v: Option<Value>, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default);
|
||||
let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0));
|
||||
m.insert("lightRotation".into(), json!(rotation));
|
||||
m.insert("lightHeight".into(), json!(height));
|
||||
m.insert("lightBehind".into(), json!(behind));
|
||||
}
|
||||
|
||||
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
|
||||
if !is_three_d(id) {
|
||||
return;
|
||||
}
|
||||
let Some(strength) = m.remove("perspective") else { return };
|
||||
if !m.contains_key("perspectiveAngle") {
|
||||
let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0);
|
||||
m.insert("perspectiveAngle".into(), json!(angle));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new effect record with default parameters (overridden by `params`).
|
||||
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
|
||||
if !catalog_index().contains_key(id) {
|
||||
@@ -661,7 +740,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)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
rotation_x: num(p, "rotationX", 0.0),
|
||||
rotation_y: num(p, "rotationY", 0.0),
|
||||
rotation_z: num(p, "rotationZ", 0.0),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
perspective: num(p, "perspectiveAngle", 0.0),
|
||||
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
@@ -200,7 +202,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,273 @@
|
||||
//! 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, "perspectiveAngle", 0.0),
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.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, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, rule, 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, rule) =
|
||||
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, rule, 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`, and the rule that fills it: the path itself for a fill, the
|
||||
/// stroke's outline (whose contours nest as its holes do) for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
|
||||
_ => (path.clone(), rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, 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, rule, 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,84 @@ 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) => {
|
||||
let mut out = plain_outline(line, &style(st, width), tol);
|
||||
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
|
||||
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
|
||||
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
|
||||
/// (SVG 2, "Path data": zero-length path segments).
|
||||
fn point_dots(line: &BezPath) -> Vec<Dot> {
|
||||
let els = line.elements();
|
||||
subpaths(line)
|
||||
.into_iter()
|
||||
.filter_map(|(r, _)| {
|
||||
let sub = els.get(r)?;
|
||||
let at = sub.first()?.end_point()?;
|
||||
let still = sub.iter().all(|e| match e {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
|
||||
PathEl::QuadTo(a, b) => *a == at && *b == at,
|
||||
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
|
||||
PathEl::ClosePath => true,
|
||||
});
|
||||
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `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");
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,25 @@ fn flatten(path: &PathData) -> Vec<Vec<Point>> {
|
||||
polys
|
||||
}
|
||||
|
||||
/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined
|
||||
/// to `strokeWidth` so the fill paints it.
|
||||
/// Whether segment `p`–`q` properly crosses an edge of `polys`: its own ends, which lie on the
|
||||
/// outline, don't count, nor does running along an edge.
|
||||
fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
|
||||
let (p, q) = (p.lerp(q, 1e-3), q.lerp(p, 1e-3));
|
||||
let side = |a: Point, b: Point, c: Point| (b - a).cross(c - a);
|
||||
// Strictly on opposite sides, beyond rounding noise (the sides are areas: length × length).
|
||||
let apart = |s1: f64, s2: f64, eps: f64| (s1 > eps && s2 < -eps) || (s1 < -eps && s2 > eps);
|
||||
let pq = (q - p).hypot();
|
||||
polys.iter().any(|pl| {
|
||||
pl.windows(2).any(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let eps = 1e-9 * pq * (b - a).hypot();
|
||||
apart(side(p, q, a), side(p, q, b), eps) && apart(side(a, b, p), side(a, b, q), eps)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region
|
||||
/// of the shape, outlined to `strokeWidth` so the fill paints it.
|
||||
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let angle = num(p, "angle", 30.0).to_radians();
|
||||
let overlap = num(p, "overlap", 0.0);
|
||||
@@ -117,7 +134,16 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
return PathData::default();
|
||||
}
|
||||
let step = spacing.max((y1 - y0) / 2000.0);
|
||||
let mut pts: Vec<Point> = vec![];
|
||||
// One zig-zag per region (#1648): a span continues a chain of the previous row as long as the
|
||||
// line from that row's end to this one's start stays inside the shape; otherwise it starts a
|
||||
// chain of its own. So no line crosses a hole or a notch. In a convex shape that line always
|
||||
// stays inside: one chain, as before.
|
||||
struct Chain {
|
||||
pts: Vec<Point>,
|
||||
end: Point,
|
||||
row: u64,
|
||||
}
|
||||
let mut chains: Vec<Chain> = vec![];
|
||||
let mut y = y0 + step / 2.0;
|
||||
let mut row = 0u64;
|
||||
while y < y1 {
|
||||
@@ -131,29 +157,42 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let mut spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
|
||||
if row % 2 == 1 {
|
||||
spans.reverse();
|
||||
}
|
||||
let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
|
||||
let open = chains.len();
|
||||
for (a, bx) in spans {
|
||||
let jitter = |k: u64| variation * noise(seed, row, k, 3);
|
||||
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
|
||||
if row.is_multiple_of(2) {
|
||||
pts.push(l);
|
||||
pts.push(r);
|
||||
} else {
|
||||
pts.push(r);
|
||||
pts.push(l);
|
||||
let even = row.is_multiple_of(2);
|
||||
let pair = if even { [l, r] } else { [r, l] };
|
||||
// Un-jittered ends of the span in drawing order.
|
||||
let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) };
|
||||
// The nearest chain of the previous row that this span can join without leaving
|
||||
// the shape.
|
||||
let prev = row.checked_sub(1);
|
||||
let pick = (0..open)
|
||||
.filter(|&k| Some(chains[k].row) == prev && !crosses_outline(chains[k].end, start, &polys))
|
||||
.min_by(|&i, &j| (chains[i].end - start).hypot().total_cmp(&(chains[j].end - start).hypot()));
|
||||
match pick {
|
||||
Some(k) => {
|
||||
let c = &mut chains[k];
|
||||
c.pts.extend(pair);
|
||||
(c.end, c.row) = (end, row);
|
||||
}
|
||||
None => chains.push(Chain { pts: pair.to_vec(), end, row }),
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
y += step;
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
let lines: Vec<SubPath> = chains
|
||||
.into_iter()
|
||||
.filter(|c| c.pts.len() >= 2)
|
||||
.map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) })
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) };
|
||||
let mut center = PathData::single(line);
|
||||
let mut center = PathData::new(lines);
|
||||
center.transform(to_local);
|
||||
let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round);
|
||||
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
|
||||
|
||||
@@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() {
|
||||
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
|
||||
}
|
||||
|
||||
/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded,
|
||||
/// whatever each one's count.
|
||||
#[test]
|
||||
fn stacked_transform_copies_stay_within_the_anchor_budget() {
|
||||
let p = json!({"copies": 1000});
|
||||
let b = square().bounds().unwrap();
|
||||
let mut path = square();
|
||||
let mut counts = vec![];
|
||||
for _ in 0..3 {
|
||||
path = crate::distort::transform_within(&path, b, &p, 0, 100);
|
||||
counts.push(path.anchor_count());
|
||||
}
|
||||
assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them");
|
||||
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
|
||||
assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25);
|
||||
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
@@ -236,6 +254,44 @@ fn roughen_seed_changes_result_and_detail_adds_points() {
|
||||
assert!(bb.x0 > -8.0 && bb.x1 < 108.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catmull_rom_keeps_a_turn_back_smooth() {
|
||||
// #1643: the path runs out to (10, 0) and turns back to (0.5, 0.2). The Catmull-Rom tangent
|
||||
// `next - prev` is almost zero there; the anchor gets handles along the chords' bisector
|
||||
// (here nearly perpendicular to them), collinear and as long as half the longer chord / 6.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.5, 0.2), Point::new(10.0, 5.0)];
|
||||
let sp = util::catmull_rom(&pts, false, 1.0);
|
||||
let a = sp.anchors[1];
|
||||
let (hin, hout) = (a.h_in - a.p, a.h_out - a.p);
|
||||
assert!(close(hin.x, -hout.x, 1e-9) && close(hin.y, -hout.y, 1e-9), "collinear: {a:?}");
|
||||
assert!(close(hout.hypot(), 0.5 * 10.0 / 6.0, 1e-3), "{a:?}");
|
||||
assert!(hout.x.abs() < 0.05 * hout.hypot(), "across the chords: {a:?}");
|
||||
// A regular turn keeps the plain Catmull-Rom tangent.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)];
|
||||
let a = util::catmull_rom(&pts, false, 1.0).anchors[1];
|
||||
let (got, want) = (a.h_out - a.p, (pts[2] - pts[0]) / 6.0);
|
||||
assert!(close(got.x, want.x, 1e-12) && close(got.y, want.y, 1e-12), "{got:?} vs {want:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roughen_smooth_makes_no_cusps() {
|
||||
// #1643: wherever Roughen's jitter turns the path back, the smooth points stay smooth: no
|
||||
// anchor's handles shrink below 1/12 of its longer chord (a cusp that PDF viewers miter).
|
||||
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0);
|
||||
for seed in 0..50 {
|
||||
let out = run("distort.roughen", json!({"size": 3, "detail": 10, "seed": seed}), &star);
|
||||
for sp in &out.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
for i in 0..n {
|
||||
let a = sp.anchors[i];
|
||||
let (prev, next) = (sp.anchors[(i + n - 1) % n].p, sp.anchors[(i + 1) % n].p);
|
||||
let chord = (a.p - prev).hypot().max((next - a.p).hypot());
|
||||
assert!((a.h_out - a.p).hypot() >= chord / 12.0 - 1e-9, "seed {seed}: cusp at {:?}", a.p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_ridge_count_and_amplitude() {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
|
||||
@@ -244,6 +300,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();
|
||||
@@ -298,6 +410,23 @@ fn twist_fixes_the_outer_circle_and_rotates_inside() {
|
||||
assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_does_not_cross_holes() {
|
||||
// #1648: a U (opening at the top, the notch x 30..70, y 0..60). Rows (y 2.5, 7.5, ...) through the prongs have
|
||||
// two spans; their zig-zags must stay apart instead of crossing the notch.
|
||||
let u = [(0.0, 0.0), (30.0, 0.0), (30.0, 60.0), (70.0, 60.0), (70.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
|
||||
let u = PathData::single(SubPath::polyline(&u.map(|(x, y)| Point::new(x, y)), true));
|
||||
let params = json!({"angle": 0, "variation": 0, "curviness": 0, "strokeWidth": 2, "spacing": 5});
|
||||
let out = run("stylize.scribble", params, &u).to_bezpath();
|
||||
for x in [40.0, 50.0, 60.0] {
|
||||
for y in [12.5, 32.5, 52.5] {
|
||||
assert_eq!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(x, y)), 0, "scribble covers the notch at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// The bar below the notch is still hatched.
|
||||
assert_ne!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(50.0, 82.5)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_produces_filled_outline_inside_bounds() {
|
||||
let out = run("stylize.scribble", json!({}), &square());
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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());
|
||||
}
|
||||
|
||||
/// Files and scripts from before the Perspective angle give `perspective` as a 0–100 % strength:
|
||||
/// each 3D effect draws it exactly as the angle that stands for it, an angle given too wins, and
|
||||
/// the current parameters carry only the angle.
|
||||
#[test]
|
||||
fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||||
let art = |n: &Node, id: &str| if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) };
|
||||
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
|
||||
let view = |extra: Value| {
|
||||
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 80});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let with = |params: Value| {
|
||||
let mut n = square_node();
|
||||
// As read from a file: the stored parameters, not a fresh effect's.
|
||||
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
|
||||
art(&n, id).unwrap().visual_bounds().unwrap()
|
||||
};
|
||||
let angle = vectorcraft_three_d::perspective_from_strength(0.6);
|
||||
let old = with(view(json!({"perspective": 60})));
|
||||
let new = with(view(json!({"perspectiveAngle": angle})));
|
||||
let none = with(view(json!({})));
|
||||
for (a, b) in [(old.x0, new.x0), (old.y0, new.y0), (old.x1, new.x1), (old.y1, new.y1)] {
|
||||
assert!((a - b).abs() < 1e-9, "{id}: {old:?} against {new:?}, none {none:?}");
|
||||
}
|
||||
assert!((old.width() - none.width()).abs() > 1.0 || (old.height() - none.height()).abs() > 1.0, "{id}: perspective shows");
|
||||
assert_eq!(with(view(json!({"perspective": 60, "perspectiveAngle": 0}))), none, "{id}: the angle wins");
|
||||
let merged = crate::merged_params(id, &view(json!({"perspective": 60})));
|
||||
assert!(merged.get("perspective").is_none() && (merged["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Effects from before Rotation and Height set the light by azimuth and elevation: each 3D effect
|
||||
/// shades its faces exactly as with the Rotation, Height and side that stand for it.
|
||||
#[test]
|
||||
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
|
||||
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
|
||||
&& let Some(c) = f.paint.color()
|
||||
{
|
||||
out.push(c.to_rgb());
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
colours(c, out);
|
||||
}
|
||||
}
|
||||
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
|
||||
for (azimuth, elevation) in [(-45.0, 45.0), (150.0, -30.0), (60.0, 80.0), (-120.0, 10.0)] {
|
||||
let with = |light: Value| {
|
||||
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 60, "lightSoftness": 0});
|
||||
p.as_object_mut().unwrap().extend(light.as_object().unwrap().clone());
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(Effect { id: id.into(), params: p, visible: true });
|
||||
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { solid_art(&n) }.unwrap();
|
||||
let mut out = vec![];
|
||||
colours(&art, &mut out);
|
||||
out
|
||||
};
|
||||
let (r, h, behind) = vectorcraft_three_d::light_from_azimuth_elevation(azimuth, elevation);
|
||||
let old = with(json!({"lightAzimuth": azimuth, "lightElevation": elevation}));
|
||||
let new = with(json!({"lightRotation": r, "lightHeight": h, "lightBehind": behind}));
|
||||
assert!(!old.is_empty() && old.len() == new.len(), "{id}");
|
||||
for (a, b) in old.iter().zip(&new) {
|
||||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{id} {azimuth} {elevation}: {a:?} against {b:?}");
|
||||
}
|
||||
}
|
||||
let merged = crate::merged_params(id, &json!({"lightAzimuth": 90, "lightElevation": 0}));
|
||||
assert!(
|
||||
merged.get("lightAzimuth").is_none() && merged["lightHeight"].as_f64().unwrap().abs() < 1e-9 && merged["lightBehind"] == json!(false),
|
||||
"{id}: {merged}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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, "perspectiveAngle": 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));
|
||||
}
|
||||
@@ -72,7 +72,7 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
|
||||
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`).
|
||||
pub use vectorcraft_doc::live::map_nonlinear;
|
||||
|
||||
/// Smooth subpath through `pts` (Catmull-Rom tangents).
|
||||
/// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns).
|
||||
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
@@ -82,13 +82,38 @@ pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
|
||||
.map(|i| {
|
||||
let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] };
|
||||
let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] };
|
||||
let t: Vec2 = (next - prev) * (tension / 6.0);
|
||||
let t: Vec2 = turn_tangent(prev, pts[i], next) * (tension / 6.0);
|
||||
Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t)
|
||||
})
|
||||
.collect();
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
/// The Catmull-Rom tangent `next − prev` at `p`. Where the path turns back sharply, `next`
|
||||
/// nearly meets `prev` and that tangent shrinks to nothing: the anchor would be a cusp, which
|
||||
/// PDF viewers draw with a miter spike (#1643). So it is kept at least half as long as the
|
||||
/// longer chord, and there it follows the bisector of the two chords (the perpendicular of the
|
||||
/// incoming one when the path reverses exactly): the turn becomes a round hairpin. Turns of
|
||||
/// less than about 150° keep the plain Catmull-Rom tangent.
|
||||
fn turn_tangent(prev: Point, p: Point, next: Point) -> Vec2 {
|
||||
let t = next - prev;
|
||||
let (a, b) = (p - prev, next - p);
|
||||
let floor = 0.5 * a.hypot().max(b.hypot());
|
||||
if t.hypot() >= floor {
|
||||
return t;
|
||||
}
|
||||
let unit = |v: Vec2| {
|
||||
let l = v.hypot();
|
||||
if l > 1e-12 { v / l } else { Vec2::ZERO }
|
||||
};
|
||||
let (u, v) = (unit(a), unit(b));
|
||||
let mut d = unit(u + v);
|
||||
if d == Vec2::ZERO {
|
||||
d = Vec2::new(-u.y, u.x);
|
||||
}
|
||||
if d == Vec2::ZERO { t } else { d * floor }
|
||||
}
|
||||
|
||||
/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`.
|
||||
pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 {
|
||||
let eps = 1e-4;
|
||||
|
||||
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
let f = |q: Point| {
|
||||
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
|
||||
let (x2, y2) = if vertical {
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
|
||||
(b2, a)
|
||||
} else {
|
||||
warp_point(style, bend, dh, dv, x, y)
|
||||
warp_point(style, bend, dh, dv, hh / hw, x, y)
|
||||
};
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
||||
Loaded 100 of 663 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user