mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 19:56:55 -04:00
Compare commits
798
Commits
u/trace-strokes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8770d2a96c | ||
|
|
c64c0120f5 | ||
|
|
40fe3154ee | ||
|
|
a812bb7dcc | ||
|
|
756ec5c5e3 | ||
|
|
0bd4ce2260 | ||
|
|
06fdeeef6c | ||
|
|
9da787f951 | ||
|
|
2853d78e66 | ||
|
|
3352e02a2b | ||
|
|
915a172b9a | ||
|
|
59c872baaa | ||
|
|
89a7992c98 | ||
|
|
15819868a2 | ||
|
|
e41f7818d6 | ||
|
|
7316cf7e39 | ||
|
|
114960482e | ||
|
|
23cfe935db | ||
|
|
b04e3ac366 | ||
|
|
60c8b8f4e2 | ||
|
|
75ee324e6c | ||
|
|
4265c2f301 | ||
|
|
a14942de9c | ||
|
|
d05ccffebc | ||
|
|
789a151ff9 | ||
|
|
a8e612d3e2 | ||
|
|
67a278b053 | ||
|
|
a6f56caa0e | ||
|
|
7e9d0be723 | ||
|
|
da43c662e0 | ||
|
|
146c43267a | ||
|
|
3b327d9512 | ||
|
|
28cb75cc59 | ||
|
|
50111c87fd | ||
|
|
716a968e7a | ||
|
|
c586cecf75 | ||
|
|
261b64251d | ||
|
|
d1089270ba | ||
|
|
ee5b0938aa | ||
|
|
76d5ca8bfa | ||
|
|
080d90ed7f | ||
|
|
68887a21c5 | ||
|
|
50152c9941 | ||
|
|
f29390c590 | ||
|
|
872e86d3f6 | ||
|
|
d6e4366885 | ||
|
|
094393959d | ||
|
|
ea164c1543 | ||
|
|
280de29c86 | ||
|
|
b68c467b45 | ||
|
|
afe0239600 | ||
|
|
a91129e422 | ||
|
|
ced45ec157 | ||
|
|
e12a670c40 | ||
|
|
82db3344bf | ||
|
|
96ce65954e | ||
|
|
80178d9902 | ||
|
|
28fe30ce58 | ||
|
|
5f7f9f6d37 | ||
|
|
1060bc6bed | ||
|
|
1ea496a04f | ||
|
|
a302783f32 | ||
|
|
bb66ec13e2 | ||
|
|
9ab58ba536 | ||
|
|
756d05d7ca | ||
|
|
6dc60a6697 | ||
|
|
5d3d28d56f | ||
|
|
832ca236d3 | ||
|
|
82d5e31be4 | ||
|
|
3fb463052e | ||
|
|
65c06f3a57 | ||
|
|
93e92d8a91 | ||
|
|
5e2dd693ae | ||
|
|
dcb759011e | ||
|
|
13a1b60d28 | ||
|
|
2c1bb5f839 | ||
|
|
f7ab3277e0 | ||
|
|
93d6411cce | ||
|
|
4614821372 | ||
|
|
671136fa66 | ||
|
|
2ce3411493 | ||
|
|
8412b4658f | ||
|
|
7ca2763587 | ||
|
|
08dde8783e | ||
|
|
b3e484702f | ||
|
|
799f94ecf1 | ||
|
|
a38c824066 | ||
|
|
0fe70314b8 | ||
|
|
568008dd47 | ||
|
|
192a671d4f | ||
|
|
dc390aba37 | ||
|
|
5ef090a0c2 | ||
|
|
699856f7d5 | ||
|
|
41c8bd2a2d | ||
|
|
c8310edea1 | ||
|
|
848d902354 | ||
|
|
80090e7623 | ||
|
|
ed6d474042 | ||
|
|
74573d6bad | ||
|
|
67f9d134ed | ||
|
|
a1b3c4804f | ||
|
|
9eddeeb952 | ||
|
|
5e75d26d29 | ||
|
|
3ae6cf28e4 | ||
|
|
20325b1a89 | ||
|
|
ba254a7481 | ||
|
|
49fcfbd145 | ||
|
|
5fc7072600 | ||
|
|
74c391c7ba | ||
|
|
e7aacec852 | ||
|
|
736abd811d | ||
|
|
265552d5f4 | ||
|
|
e6b28f0e80 | ||
|
|
e2df2e774c | ||
|
|
2843687ef4 | ||
|
|
32dbc4b641 | ||
|
|
a69371656b | ||
|
|
4b8a987445 | ||
|
|
52b072873d | ||
|
|
0c9ce426e7 | ||
|
|
98220d1929 | ||
|
|
c141d54dad | ||
|
|
f16f542b9b | ||
|
|
0db9d6f757 | ||
|
|
a23a682960 | ||
|
|
2d1e09cf32 | ||
|
|
1838a4e89a | ||
|
|
d02802f391 | ||
|
|
20525c9346 | ||
|
|
3ece6428b3 | ||
|
|
e67a9f558a | ||
|
|
a9d72d01b1 | ||
|
|
81e2c81890 | ||
|
|
fe5c78f6a5 | ||
|
|
57a3cfab73 | ||
|
|
4dcb0900a6 | ||
|
|
80be476b04 | ||
|
|
cddc5a6848 | ||
|
|
d6f6cb9b5f | ||
|
|
5aab51bf87 | ||
|
|
3bcce6593a | ||
|
|
192ca0b8e6 | ||
|
|
b64c88b4d9 | ||
|
|
78c5973609 | ||
|
|
99a6c06681 | ||
|
|
c42b31f48a | ||
|
|
4f794ea8db | ||
|
|
cc467d1ef4 | ||
|
|
bfe6670539 | ||
|
|
bf10fd64f6 | ||
|
|
2346a4acfb | ||
|
|
5b9a9b6d6c | ||
|
|
82d9143502 | ||
|
|
cebc4ed93e | ||
|
|
29bf7b7e06 | ||
|
|
e965c0daa2 | ||
|
|
aaeb658292 | ||
|
|
d541eb9476 | ||
|
|
cf0be249ba | ||
|
|
0594f04e30 | ||
|
|
b15c28b7c0 | ||
|
|
1ab3940b3d | ||
|
|
2abc1d61f7 | ||
|
|
1e8f5025c6 | ||
|
|
83f40f135a | ||
|
|
8b5e9e4927 | ||
|
|
3a7d422046 | ||
|
|
ad3531d464 | ||
|
|
4913418d1c | ||
|
|
9e1590769e | ||
|
|
55fadb6afe | ||
|
|
28daaf473c | ||
|
|
fc51d66659 | ||
|
|
7b8730ec14 | ||
|
|
c54a9b0bef | ||
|
|
a86ff7f177 | ||
|
|
7135a377f8 | ||
|
|
91e1434cbf | ||
|
|
5a43337a42 | ||
|
|
5252c3d86a | ||
|
|
1ba448f7f3 | ||
|
|
032b7b7be1 | ||
|
|
8fffa4f6f9 | ||
|
|
dbcd32d21a | ||
|
|
5d8b5ddc6c | ||
|
|
75b91e9ef3 | ||
|
|
4714567240 | ||
|
|
477ff16357 | ||
|
|
c7aac7c250 | ||
|
|
fd8cd62d17 | ||
|
|
04ed30a23d | ||
|
|
6f9ee7825b | ||
|
|
580aa8daa5 | ||
|
|
f14fbded0e | ||
|
|
446ccb1fd0 | ||
|
|
4440463315 | ||
|
|
6928f422d3 | ||
|
|
c6837ca3ae | ||
|
|
b34b9dadcc | ||
|
|
867e72b085 | ||
|
|
0ffae0c3fa | ||
|
|
9e832d32da | ||
|
|
29eb490381 | ||
|
|
697f238b60 | ||
|
|
8ca25c955e | ||
|
|
5a5f1a0995 | ||
|
|
799155cdd0 | ||
|
|
1f6ef9dcf3 | ||
|
|
fc0e926cda | ||
|
|
940f425a01 | ||
|
|
ea59b066d4 | ||
|
|
427cd65f30 | ||
|
|
ed96a12196 | ||
|
|
be97caf90b | ||
|
|
c48aba77e3 | ||
|
|
6a56c7a5f6 | ||
|
|
4a6d52e7ea | ||
|
|
fda7c9fae3 | ||
|
|
0b10aa67a8 | ||
|
|
2133a21096 | ||
|
|
ca8fb9a543 | ||
|
|
faba555c63 | ||
|
|
97d7e7488a | ||
|
|
029ab098a3 | ||
|
|
2422d0ea5b | ||
|
|
e24edbab41 | ||
|
|
16af375873 | ||
|
|
c246fc2f0e | ||
|
|
c5a1504e99 | ||
|
|
b9341d3b3b | ||
|
|
f3b4c3ec78 | ||
|
|
136810bee2 | ||
|
|
90e6ff3de1 | ||
|
|
dc179c3671 | ||
|
|
8af0459232 | ||
|
|
e93ba4c83f | ||
|
|
9ebd30b8bb | ||
|
|
31ffbb2152 | ||
|
|
ca145c5265 | ||
|
|
16b8aeb6b7 | ||
|
|
76888366e7 | ||
|
|
1682d59b65 | ||
|
|
6545d8b886 | ||
|
|
fb6fc437c3 | ||
|
|
d5a672fda2 | ||
|
|
312a939646 | ||
|
|
dfdb98463f | ||
|
|
6feec01d85 | ||
|
|
2cdd00cf7b | ||
|
|
8958dfe60a | ||
|
|
e5c12d0487 | ||
|
|
e797b68220 | ||
|
|
f4b0e0f27b | ||
|
|
2480017af0 | ||
|
|
312456a769 | ||
|
|
bbda88bc71 | ||
|
|
a324b0e25f | ||
|
|
39faa02fe5 | ||
|
|
57ec0e6d7f | ||
|
|
6341519444 | ||
|
|
d4fa50f262 | ||
|
|
6c4804383a | ||
|
|
263fcff7b1 | ||
|
|
3c9fda57df | ||
|
|
59dfe0834c | ||
|
|
b70ba0f82b | ||
|
|
3a0d1ce970 | ||
|
|
6048fd6b8e | ||
|
|
e479e62317 | ||
|
|
8f1d730ccb | ||
|
|
043f2dff3c | ||
|
|
b8d1bdea40 | ||
|
|
8ca5615dca | ||
|
|
0a0d22df57 | ||
|
|
f3eab99283 | ||
|
|
f253d2b3e3 | ||
|
|
70723a799b | ||
|
|
9bbe74bd19 | ||
|
|
f3363bddc2 | ||
|
|
80f084d8cf | ||
|
|
f614428a92 | ||
|
|
7f899ef293 | ||
|
|
e3e290d43b | ||
|
|
b069019cdd | ||
|
|
e55122afc5 | ||
|
|
73f3731dbf | ||
|
|
5e374712dc | ||
|
|
d726f6596c | ||
|
|
98511401a0 | ||
|
|
f2f1945973 | ||
|
|
d35ed26b0f | ||
|
|
aaa7359c7b | ||
|
|
7608639021 | ||
|
|
e6170de1a2 | ||
|
|
a0f8a88e82 | ||
|
|
6d898558fd | ||
|
|
5ff34782cb | ||
|
|
07c6b938bc | ||
|
|
cb9522f7cd | ||
|
|
0155eaf468 | ||
|
|
5a83bcfafa | ||
|
|
66dbe40ea6 | ||
|
|
53c59f06de | ||
|
|
513685a221 | ||
|
|
4a1323ad07 | ||
|
|
8da47f11f8 | ||
|
|
18f8a1bdcf | ||
|
|
95d27fe390 | ||
|
|
ff7f47119d | ||
|
|
01c66e51b7 | ||
|
|
23a0a5f857 | ||
|
|
651e2e1a57 | ||
|
|
5a5bb73aa9 | ||
|
|
88e950a0d7 | ||
|
|
99bdcc6633 | ||
|
|
ef53890c28 | ||
|
|
011a1a0314 | ||
|
|
17c512f5c9 | ||
|
|
ec602d7e27 | ||
|
|
bb5f16c101 | ||
|
|
b8041d3745 | ||
|
|
0205a3332f | ||
|
|
a7425f27c1 | ||
|
|
fb803dbdae | ||
|
|
81420b87c8 | ||
|
|
57b5931c83 | ||
|
|
ce02a42f35 | ||
|
|
4b60fcead7 | ||
|
|
3bef84aaa6 | ||
|
|
98d1d738a2 | ||
|
|
1c30cbf88c | ||
|
|
d069f0fe98 | ||
|
|
01b3599227 | ||
|
|
00b02c1bdb | ||
|
|
ff33e0556f | ||
|
|
35f43d631f | ||
|
|
bbad808a02 | ||
|
|
d68e14a393 | ||
|
|
4d63227223 | ||
|
|
720ce5ed73 | ||
|
|
b5a0fee1ea | ||
|
|
103c6ebdfb | ||
|
|
25df070576 | ||
|
|
a14abc3177 | ||
|
|
a3081b9f53 | ||
|
|
710b6058ca | ||
|
|
0a4e9ea8c8 | ||
|
|
e22b6a5d45 | ||
|
|
5473e3a5c5 | ||
|
|
8e58b9a23f | ||
|
|
0c3a5929c7 | ||
|
|
7960fa722f | ||
|
|
ac4d69032c | ||
|
|
83759c0e94 | ||
|
|
37c3eb0d01 | ||
|
|
f5fcb30745 | ||
|
|
53eb76f001 | ||
|
|
2ebc607ed2 | ||
|
|
a8e9b9d86e | ||
|
|
5162f830d0 | ||
|
|
62ab3bd797 | ||
|
|
18c1bb0d55 | ||
|
|
1524ceae09 | ||
|
|
a59621fa78 | ||
|
|
c53e161e37 | ||
|
|
8b22b07e09 | ||
|
|
8a8b21385b | ||
|
|
ccc8a246ae | ||
|
|
fda8434d93 | ||
|
|
62089aa333 | ||
|
|
0a09706453 | ||
|
|
d939e46542 | ||
|
|
47f826b16f | ||
|
|
0ccc501ef9 | ||
|
|
bdff89b5c1 | ||
|
|
d47e5cf461 | ||
|
|
18186dbeb7 | ||
|
|
45922c40ef | ||
|
|
f6b9ab905e | ||
|
|
e9fc3c57ae | ||
|
|
256b052981 | ||
|
|
93742621bc | ||
|
|
8d4a814c81 | ||
|
|
f53e7a4c52 | ||
|
|
10b35a4218 | ||
|
|
ceb69c20d1 | ||
|
|
5bcf040659 | ||
|
|
8e72916a69 | ||
|
|
07b45e8dd3 | ||
|
|
8d288337f5 | ||
|
|
d0c302c1a3 | ||
|
|
d425aede37 | ||
|
|
7beebfabb9 | ||
|
|
3af1976d19 | ||
|
|
d5abc71677 | ||
|
|
6986d87771 | ||
|
|
26a9c7a4cc | ||
|
|
ff79f332a7 | ||
|
|
fc14c38883 | ||
|
|
f5eaf75097 | ||
|
|
21749106d3 | ||
|
|
cc0aec12ae | ||
|
|
a23b2370bc | ||
|
|
e18efebf60 | ||
|
|
ef959c00bd | ||
|
|
e2d796b555 | ||
|
|
268c2f2703 | ||
|
|
c9ead98f1c | ||
|
|
baadbe2c67 | ||
|
|
ce133351fd | ||
|
|
313b1a52b9 | ||
|
|
7844f46c65 | ||
|
|
06221726fb | ||
|
|
37cebc47ee | ||
|
|
7217dcd121 | ||
|
|
6f23190925 | ||
|
|
67e17c1b66 | ||
|
|
8aea665c4e | ||
|
|
c2d832ebc0 | ||
|
|
6ce1a622be | ||
|
|
9d813e906a | ||
|
|
9fc7fd7038 | ||
|
|
32df15a554 | ||
|
|
91ed4853a8 | ||
|
|
bcae7ca486 | ||
|
|
eb320593dc | ||
|
|
b5b9264d71 | ||
|
|
0c11ff656c | ||
|
|
433d78284d | ||
|
|
51673cd098 | ||
|
|
f703e6bd85 | ||
|
|
173c88d2a1 | ||
|
|
8354dbcd74 | ||
|
|
cf98b7f368 | ||
|
|
9e90464f20 | ||
|
|
a69ddea930 | ||
|
|
cbe793e059 | ||
|
|
210b98e902 | ||
|
|
56df5969f5 | ||
|
|
b12a52f724 | ||
|
|
05f6e2e6df | ||
|
|
0946bc5b74 | ||
|
|
0a33cff3f6 | ||
|
|
d80950b3f3 | ||
|
|
664e2cec5f | ||
|
|
2ecba1fb9c | ||
|
|
52960faf63 | ||
|
|
a067177778 | ||
|
|
d9c9526898 | ||
|
|
8c889279f8 | ||
|
|
d1108bdbca | ||
|
|
04244fa2a2 | ||
|
|
612a13b39c | ||
|
|
f1e634af19 | ||
|
|
8c14609370 | ||
|
|
b076025a31 | ||
|
|
3bb87989df | ||
|
|
a40e8f5aff | ||
|
|
1a8e0456cb | ||
|
|
7f1ad31a7b | ||
|
|
6fd31d4dbd | ||
|
|
f9b051d3d1 | ||
|
|
6bc0222074 | ||
|
|
eff2ccdc34 | ||
|
|
c73871eacc | ||
|
|
2afd2654e8 | ||
|
|
725c3881d4 | ||
|
|
aa93258ea7 | ||
|
|
fe735f0e7d | ||
|
|
65e2168791 | ||
|
|
c44ece3113 | ||
|
|
014021577b | ||
|
|
abe89d5176 | ||
|
|
0c0d2448da | ||
|
|
e01a65d8a9 | ||
|
|
5a120e323b | ||
|
|
5de774835b | ||
|
|
1a280e1402 | ||
|
|
8bd8e2b298 | ||
|
|
cd1b590aa8 | ||
|
|
dcd0da87cb | ||
|
|
f774968b20 | ||
|
|
f1fd8e85e8 | ||
|
|
991ce2c635 | ||
|
|
0efcaeb558 | ||
|
|
17d2493616 | ||
|
|
3ae71c16ae | ||
|
|
ecf86ca21e | ||
|
|
886b33c584 | ||
|
|
2af6f5bfbe | ||
|
|
cde9ee8a71 | ||
|
|
a699a26221 | ||
|
|
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 |
No files matched your search
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
|
||||
@@ -35,6 +35,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
timeout-minutes: 10
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets here.
|
||||
@@ -63,6 +64,7 @@ jobs:
|
||||
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
timeout-minutes: 90
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
@@ -113,6 +115,7 @@ jobs:
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
timeout-minutes: 90
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
@@ -174,6 +177,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
timeout-minutes: 90
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
@@ -243,6 +247,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
timeout-minutes: 90
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
@@ -335,6 +340,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
@@ -378,6 +384,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
timeout-minutes: 90
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
@@ -458,6 +465,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
timeout-minutes: 10
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
|
||||
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -133,6 +134,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-draw-normal.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-ellipse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-fill-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-filter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-flare.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-folder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -333,3 +335,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
|
||||
## Assets embedded by the PDF backend
|
||||
|
||||
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
|
||||
|
||||
| Crate / file | Author | Source | Licence | SHA-256 | Use |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
Generated
+84
-51
@@ -806,6 +806,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1372,24 +1383,13 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
|
||||
dependencies = [
|
||||
"fontconfig-parser",
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
]
|
||||
@@ -1777,8 +1777,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1788,7 +1787,7 @@ dependencies = [
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
@@ -1796,8 +1795,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -1821,8 +1819,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -1831,6 +1828,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -2221,8 +2219,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3640,9 +3637,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",
|
||||
]
|
||||
@@ -3995,6 +3992,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -4379,6 +4387,15 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
@@ -4490,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4510,6 +4527,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
@@ -4524,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4535,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4547,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4563,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4585,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4597,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4614,8 +4632,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4629,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4654,6 +4673,7 @@ dependencies = [
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-scriptcheck",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -4664,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4685,10 +4705,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4699,7 +4720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4711,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4730,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4744,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4756,18 +4777,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"roxmltree 0.21.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4781,11 +4802,12 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4799,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4815,16 +4837,25 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
"wat",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4848,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4865,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4885,7 +4916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4893,7 +4924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4901,6 +4932,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
@@ -4908,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4920,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4949,11 +4981,12 @@ dependencies = [
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6063,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+24
-6
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
@@ -20,7 +21,9 @@ vectorcraft-doc = { path = "crates/doc" }
|
||||
vectorcraft-pathops = { path = "crates/pathops" }
|
||||
vectorcraft-brush = { path = "crates/brush" }
|
||||
vectorcraft-trace = { path = "crates/trace" }
|
||||
vectorcraft-text = { path = "crates/text" }
|
||||
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
|
||||
# library crate never forces them on an embedder that supplies its fonts at run time.
|
||||
vectorcraft-text = { path = "crates/text", default-features = false }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
vectorcraft-render = { path = "crates/render" }
|
||||
@@ -37,6 +40,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"] }
|
||||
@@ -57,16 +61,24 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
|
||||
wat = "1.228"
|
||||
|
||||
# rendering / formats
|
||||
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
usvg = { version = "0.48", default-features = false, features = ["svgz"] }
|
||||
# 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
|
||||
@@ -107,3 +119,9 @@ opt-level = 1
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Shared PDF backend
|
||||
|
||||
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
|
||||
are fetched from https://github.com/storytold/craft-libs at revision
|
||||
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
|
||||
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
|
||||
crate's upstream licence files. The patch ledger is at
|
||||
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
|
||||
|
||||
Assets already bundled through Hayro's default features retain their licences:
|
||||
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
|
||||
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
|
||||
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
|
||||
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
|
||||
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
|
||||
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
|
||||
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
@@ -211,7 +211,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
|
||||
all of Illustrator): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
@@ -220,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, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, 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, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
|
||||
[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.
|
||||
|
||||
+33
-5
@@ -1,12 +1,15 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**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-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -35,7 +38,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 (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 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 (50–85 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 +55,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) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 74% (measured) | 50–85 | [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) |
|
||||
@@ -102,14 +105,14 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Hindi | `hi` | none | 0% |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | partial | 68% |
|
||||
| Portuguese (Brazil) | `pt-br` | full | 100% |
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -125,10 +128,28 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
||||
|
||||
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
| 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 the Direct Selection tool, double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character; pressing Escape finishes editing and returns to the selection tool used before double-clicking, and in isolation mode the first Escape exits editing while preserving isolation until a second Escape. 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. |
|
||||
@@ -155,6 +176,13 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
| 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 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 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] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
@@ -84,7 +84,7 @@ USAGE:
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
@@ -93,7 +93,13 @@ USAGE:
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read.
|
||||
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.
|
||||
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
|
||||
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
|
||||
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
|
||||
visible), --transparent-images (default flatten, with the art under them; omit) and
|
||||
--outline-text (type as outlines instead of text).
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -108,7 +114,8 @@ USAGE:
|
||||
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).
|
||||
no budget yet and prints memory use (the working set on Windows), with the peak so far on
|
||||
Windows and Linux.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -222,6 +229,9 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
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>;
|
||||
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
|
||||
// `transparentImages`); the engine checks their values.
|
||||
let mut legacy: Vec<(&str, String)> = vec![];
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
@@ -232,24 +242,35 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
"--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,
|
||||
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
|
||||
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
|
||||
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
|
||||
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();
|
||||
if editing_data {
|
||||
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}"))?;
|
||||
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}"))?;
|
||||
}
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
for (key, value) in legacy {
|
||||
params[key] = json!(value);
|
||||
}
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -136,31 +136,63 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
Ok((handle, segment))
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
/// Resident memory of this process in MB (`ps`), if available, with the most it has held so far
|
||||
/// on Linux (`VmHWM` in `/proc/self/status`). The peak is None on macOS, which has no `/proc`.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
fn rss_mb() -> Option<(f64, 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)
|
||||
let peak = std::fs::read_to_string("/proc/self/status").ok().and_then(|status| vm_hwm_mb(&status));
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| (kb / 1024.0, peak))
|
||||
}
|
||||
|
||||
/// The peak resident set size in MB from the `VmHWM` line of a `/proc/<pid>/status` text, which
|
||||
/// gives it in kB. It is None when the text has no `VmHWM` line in kB.
|
||||
#[cfg(any(not(windows), test))]
|
||||
fn vm_hwm_mb(status: &str) -> Option<f64> {
|
||||
let kb = status.lines().find_map(|line| line.strip_prefix("VmHWM:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(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.
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere. The same call
|
||||
/// gives the peak working set, the most the working set has held so far.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
fn rss_mb() -> Option<(f64, Option<f64>)> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
let mb = |bytes: usize| bytes as f64 / (1024.0 * 1024.0);
|
||||
Some((mb(counters.WorkingSetSize), Some(mb(counters.PeakWorkingSetSize))))
|
||||
}
|
||||
|
||||
/// A tracked row of `--deep`: the work's name and its median in ms. Names are padded to 54
|
||||
/// characters.
|
||||
fn tracked_row(name: &str, ms: f64) -> String {
|
||||
format!(" {name:<54} {ms:>10.2} ms")
|
||||
}
|
||||
|
||||
/// A memory row of `--deep`: the reading's name and value in MB, then the peak so far when
|
||||
/// there is one. Names are padded to 56 characters.
|
||||
fn memory_row(name: &str, mb: f64, peak: Option<f64>) -> String {
|
||||
let peak = peak.map_or_else(String::new, |peak| format!(" peak {peak:>8.0} MB"));
|
||||
format!(" {name:<56} {mb:>8.0} MB{peak}")
|
||||
}
|
||||
|
||||
/// A row of the budget table: the budget's name, the median and the budget in ms, then "ok" or
|
||||
/// "OVER". Names are padded to 42 characters, as in the table's header.
|
||||
fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
|
||||
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
|
||||
}
|
||||
|
||||
/// `--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.
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
|
||||
/// the peak so far where `rss_mb` reads one.
|
||||
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 mem: Vec<(String, f64, Option<f64>)> = vec![];
|
||||
let mut note = |what: &str| {
|
||||
if let Some(m) = rss_mb() {
|
||||
mem.push((what.to_string(), m));
|
||||
if let Some((m, peak)) = rss_mb() {
|
||||
mem.push((what.to_string(), m, peak));
|
||||
}
|
||||
};
|
||||
note("start");
|
||||
@@ -271,14 +303,15 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
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)));
|
||||
note("after the SVG export and save→open→save");
|
||||
outln!(" tracked (no budget yet)");
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
outln!("{}", tracked_row(name, *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}"));
|
||||
for (what, mb, peak) in &mem {
|
||||
outln!("{}", memory_row(&format!("{memory} {what}"), *mb, *peak));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -381,11 +414,11 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<42} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!("{}", budget_row(name, *ms, *budget, ok));
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
@@ -427,7 +460,57 @@ mod tests {
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
let mb = rss_mb().map(|(mb, _)| mb);
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
|
||||
/// On Windows and Linux `rss_mb` also gives the peak: after this test writes 64 MB and frees
|
||||
/// it, the peak is no more than 16 MB below the resident memory read while it held the 64 MB.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn freed_memory_stays_in_the_peak() {
|
||||
let buffer = std::hint::black_box(vec![1u8; 64 << 20]);
|
||||
let held = rss_mb();
|
||||
drop(buffer);
|
||||
let (Some((held, _)), Some((now, peak))) = (held, rss_mb()) else {
|
||||
eprintln!("no memory reading (Linux without `ps`): nothing to check");
|
||||
return;
|
||||
};
|
||||
assert!(peak.is_some_and(|peak| peak > held - 16.0), "holding 64 MB: {held:.1} MB, after freeing it: {now:.1} MB, peak {peak:?}");
|
||||
}
|
||||
|
||||
/// `vm_hwm_mb` reads the peak resident set size from the `VmHWM` line of a status text that
|
||||
/// also has a `VmRSS` line.
|
||||
#[test]
|
||||
fn vm_hwm_is_the_peak_resident_set_size() {
|
||||
let status = "Name:\tvectorcraft-cli\nVmPeak:\t 2097152 kB\nVmSize:\t 1572864 kB\nVmHWM:\t 819200 kB\nVmRSS:\t 204800 kB\n";
|
||||
assert_eq!(vm_hwm_mb(status), Some(800.0));
|
||||
// A text without a `VmHWM` line gives no peak.
|
||||
assert_eq!(vm_hwm_mb("Name:\tkthreadd\nState:\tS (sleeping)\n"), None);
|
||||
}
|
||||
|
||||
/// A memory row ends with the peak when there is one; without a peak it ends with the value
|
||||
/// in MB. With the longest name on Windows, the value and the peak line up with the other rows.
|
||||
#[test]
|
||||
fn memory_rows_end_with_the_peak_when_there_is_one() {
|
||||
let row = memory_row("working set start", 346.4, Some(470.2));
|
||||
assert!(row.starts_with(" working set start ") && row.ends_with(" 346 MB peak 470 MB"), "{row}");
|
||||
let longest = memory_row("working set after rendering the photos with 3 renderers", 2514.0, Some(2541.0));
|
||||
assert_eq!(longest.chars().count(), row.chars().count(), "{longest}\n{row}");
|
||||
let row = memory_row("memory start", 346.4, None);
|
||||
assert!(row.starts_with(" memory start ") && row.ends_with(" 346 MB") && !row.contains("peak"), "{row}");
|
||||
}
|
||||
|
||||
/// With the longest names, the values of a tracked row and of the budget table's rows line
|
||||
/// up with the rows that have short names.
|
||||
#[test]
|
||||
fn the_longest_tracked_and_budget_names_fit_their_columns() {
|
||||
let longest = tracked_row("render 4 embedded 24 MP photos with 3 renderers (cold)", 774.61);
|
||||
let short = tracked_row("Pathfinder crop, 300 paths", 30.94);
|
||||
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
|
||||
for name in ["handle drag, 5,000-anchor path (per move)", "segment drag, 5,000-anchor path (per move)"] {
|
||||
let (longest, short) = (budget_row(name, 0.12, 8.0, true), budget_row("export SVG", 7.81, 500.0, true));
|
||||
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,10 @@ fn help_lists_readable_formats() {
|
||||
#[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}");
|
||||
assert!(
|
||||
out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times") && out.contains("with the peak so far on"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
@@ -99,3 +102,73 @@ 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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_writes_legacy_ai_with_its_options() {
|
||||
// Two artboards, a shown rectangle and a hidden one.
|
||||
let src = tmp("legacy.vectorcraft");
|
||||
ok(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"file.new",
|
||||
"--params",
|
||||
r#"{"width":120,"height":80,"artboards":2}"#,
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":10,"y":10,"width":20,"height":10}"#,
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":40,"y":10,"width":20,"height":10}"#,
|
||||
"--cmd",
|
||||
"object.hide",
|
||||
"--export",
|
||||
&src,
|
||||
]);
|
||||
// One file per artboard, named after it; hidden objects in a hidden layer by default.
|
||||
let out = tmp("legacy.ai");
|
||||
let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap();
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("hidden layer"), "{r}");
|
||||
// One artboard is the file itself; the hidden rectangle left out.
|
||||
let one = tmp("legacy-one.ai");
|
||||
let r: Value = serde_json::from_str(&ok(&[
|
||||
"convert",
|
||||
&src,
|
||||
&one,
|
||||
"--ai-version",
|
||||
"7",
|
||||
"--range",
|
||||
"1",
|
||||
"--hidden-objects",
|
||||
"omit",
|
||||
"--transparent-images",
|
||||
"omit",
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
|
||||
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
|
||||
// A value the engine doesn't take is an error.
|
||||
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
@@ -28,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -2,12 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="VectorCraft">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="theme-color" content="#262626">
|
||||
<title>VectorCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
|
||||
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
|
||||
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
|
||||
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
|
||||
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
|
||||
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
|
||||
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
display: block; touch-action: none; }
|
||||
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
|
||||
//! hover, palm rejection, fingers navigate once a pen has been seen).
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
@@ -18,6 +20,8 @@
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in
|
||||
//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from
|
||||
//! a finger and see each touch twice (as a pointer and as a touch event).
|
||||
//!
|
||||
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
|
||||
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
|
||||
//! where the pen can (Apple Pencil hover).
|
||||
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
|
||||
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
|
||||
//! only once a pen has been seen ([`touch::set_source`]), for this page session only.
|
||||
//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers
|
||||
//! already down (a palm resting first).
|
||||
//!
|
||||
//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen
|
||||
//! contact, fingers and their DOM touch events also stay with eframe (including its text agent).
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::cell::RefCell;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
|
||||
use vectorcraft_ui_egui::touch::{self, Source};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger).
|
||||
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
|
||||
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
|
||||
|
||||
/// The pen and finger contacts followed, and the egui events they made since the last frame.
|
||||
#[derive(Default)]
|
||||
pub struct Pointers {
|
||||
events: Vec<Event>,
|
||||
/// The pen pressing, by pointer id.
|
||||
pen: Option<i32>,
|
||||
/// The fingers down, by pointer id.
|
||||
fingers: Vec<i32>,
|
||||
/// The contact driving egui's pointer.
|
||||
primary: Option<i32>,
|
||||
button: Option<PointerButton>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SharedPointers = Rc<RefCell<Pointers>>;
|
||||
|
||||
/// Browser-independent snapshot, also used by the routing regression tests.
|
||||
struct Input<'a> {
|
||||
pointer_type: &'a str,
|
||||
id: i32,
|
||||
button: i16,
|
||||
pressure: f32,
|
||||
modifiers: egui::Modifiers,
|
||||
}
|
||||
|
||||
// Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons.
|
||||
fn button(number: i16) -> Option<PointerButton> {
|
||||
match number {
|
||||
0 => Some(PointerButton::Primary),
|
||||
1 => Some(PointerButton::Middle),
|
||||
2 => Some(PointerButton::Secondary),
|
||||
3 => Some(PointerButton::Extra1),
|
||||
4 => Some(PointerButton::Extra2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Pointers {
|
||||
/// The events since the last frame, for the frame's raw input.
|
||||
pub fn take(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
|
||||
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
|
||||
}
|
||||
|
||||
fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) {
|
||||
self.primary = Some(id);
|
||||
self.button = Some(button);
|
||||
self.events.push(Event::ModifiersChanged(modifiers));
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers });
|
||||
}
|
||||
|
||||
fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
|
||||
self.primary = None;
|
||||
if let Some(button) = self.button.take() {
|
||||
self.events.push(Event::ModifiersChanged(modifiers));
|
||||
self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers });
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every finger down (a palm under a pen landing).
|
||||
fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
|
||||
for id in std::mem::take(&mut self.fingers) {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos, modifiers);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paired DOM touch events must follow the same routing as pointer events.
|
||||
fn captures_touch(&self, on_canvas: bool) -> bool {
|
||||
touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty())
|
||||
}
|
||||
|
||||
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
|
||||
fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
|
||||
let id = e.id;
|
||||
match e.pointer_type {
|
||||
"pen" => {
|
||||
let force = Some(e.pressure.clamp(0.0, 1.0));
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
if !on_canvas && self.pen.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(button) = button(e.button) else { return false };
|
||||
touch::set_source(Source::Pen);
|
||||
self.cancel_fingers(pos, e.modifiers);
|
||||
self.pen = Some(id);
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
|
||||
self.press(id, pos, button, e.modifiers);
|
||||
}
|
||||
"pointermove" if self.pen == Some(id) => {
|
||||
// The positions the pen reported between two events (up to 240 a second
|
||||
// on an iPad), each with its pressure, so fast strokes keep their curve.
|
||||
for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) {
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1));
|
||||
self.events.push(Event::PointerMoved(c.0));
|
||||
}
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force);
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
// Hovering (Apple Pencil hover): the pointer follows without pressing.
|
||||
"pointermove" => {
|
||||
if self.primary.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas {
|
||||
return false;
|
||||
}
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.pen == Some(id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.pen = None;
|
||||
self.touch(PEN_DEVICE, id, phase, pos, None);
|
||||
self.release(pos, e.modifiers);
|
||||
}
|
||||
// A pen leaving the hover range, or an event of a press we didn't take.
|
||||
_ => {
|
||||
if kind == "pointerleave" && self.primary.is_none() && on_canvas {
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
return self.pen.is_some();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
// Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the
|
||||
// page resets this policy; an old localStorage marker is deliberately ignored.
|
||||
"touch" if !touch::pen_seen() => false,
|
||||
"touch" => {
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
// Palm rejection: nothing presses while the pen does.
|
||||
if self.pen.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas && self.fingers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.fingers.push(id);
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None);
|
||||
if self.primary.is_none() && self.fingers.len() == 1 {
|
||||
touch::set_source(Source::Finger);
|
||||
self.press(id, pos, PointerButton::Primary, e.modifiers);
|
||||
}
|
||||
}
|
||||
"pointermove" if self.fingers.contains(&id) => {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.fingers.contains(&id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.fingers.retain(|f| *f != id);
|
||||
self.touch(FINGER_DEVICE, id, phase, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos, e.modifiers);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
// A rejected palm's later events, or one we never took.
|
||||
_ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas,
|
||||
}
|
||||
true
|
||||
}
|
||||
"mouse" => {
|
||||
if kind == "pointerdown" {
|
||||
touch::set_source(Source::Mouse);
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch
|
||||
/// events that follow them), for the runner's frames. Mouse events stay eframe's.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into();
|
||||
let options = web_sys::AddEventListenerOptions::new();
|
||||
options.set_capture(true);
|
||||
options.set_passive(false);
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
let z = runner.egui_ctx().zoom_factor();
|
||||
let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z);
|
||||
let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z);
|
||||
let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove"
|
||||
&& e.pointer_type() == "pen"
|
||||
// Safari before 17 hasn't it, and calling a missing method would abort the app.
|
||||
&& js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false)
|
||||
{
|
||||
e.get_coalesced_events()
|
||||
.iter()
|
||||
.filter_map(|c| c.dyn_into::<web_sys::PointerEvent>().ok())
|
||||
.map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0)))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let pointer_type = e.pointer_type();
|
||||
let input = Input {
|
||||
pointer_type: &pointer_type,
|
||||
id: e.pointer_id(),
|
||||
button: e.button(),
|
||||
pressure: e.pressure(),
|
||||
// Same semantics as eframe's modifiers_from_mouse_event.
|
||||
modifiers: egui::Modifiers {
|
||||
alt: e.alt_key(),
|
||||
ctrl: e.ctrl_key(),
|
||||
shift: e.shift_key(),
|
||||
mac_cmd: e.meta_key(),
|
||||
command: e.ctrl_key() || e.meta_key(),
|
||||
},
|
||||
};
|
||||
if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) {
|
||||
return;
|
||||
}
|
||||
e.stop_immediate_propagation();
|
||||
// No compatibility mouse events, no text selection or magnifier.
|
||||
e.prevent_default();
|
||||
if kind == "pointerup" {
|
||||
// Keys typed next (a Magic Keyboard) go to the app.
|
||||
canvas.focus().ok();
|
||||
// Safari lets a page open the share sheet, a download or the on-screen keyboard
|
||||
// only during the user's gesture: handle the release now.
|
||||
runner.logic();
|
||||
}
|
||||
runner.egui_ctx().request_repaint();
|
||||
})?;
|
||||
}
|
||||
// The touch events behind those pointer events: eframe would see each touch a second time,
|
||||
// and Safari would scroll, zoom or select.
|
||||
for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let p = pointers.borrow();
|
||||
if p.captures_touch(on_canvas) {
|
||||
e.stop_immediate_propagation();
|
||||
if e.cancelable() {
|
||||
e.prevent_default();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(pointer_type: &str, button: i16) -> Input<'_> {
|
||||
Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() }
|
||||
}
|
||||
|
||||
fn reset() {
|
||||
touch::set_pen_seen(false);
|
||||
touch::set_source(Source::Mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_buttons_and_modifiers_survive_press_release_and_cancel() {
|
||||
for (number, expected) in [
|
||||
(0, PointerButton::Primary),
|
||||
(1, PointerButton::Middle),
|
||||
(2, PointerButton::Secondary),
|
||||
(3, PointerButton::Extra1),
|
||||
(4, PointerButton::Extra2),
|
||||
] {
|
||||
for end in ["pointerup", "pointercancel"] {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
let mut e = input("pen", number);
|
||||
e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true };
|
||||
let pos = Pos2::new(30.0, 40.0);
|
||||
assert!(p.feed("pointerdown", &e, pos, &[], true));
|
||||
assert!(p.feed(end, &e, pos, &[], false));
|
||||
let buttons: Vec<_> = p
|
||||
.take()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]);
|
||||
assert!(p.pen.is_none() && p.primary.is_none());
|
||||
}
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
let pos = Pos2::new(10.0, 20.0);
|
||||
for pointer_type in ["touch", "mouse"] {
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
|
||||
assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true));
|
||||
}
|
||||
}
|
||||
assert!(p.take().is_empty());
|
||||
assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen");
|
||||
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
|
||||
assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true));
|
||||
assert!(p.captures_touch(true));
|
||||
assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true));
|
||||
assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true));
|
||||
// A new page starts from false, without reading any old persistent marker.
|
||||
reset();
|
||||
let mut next_page = Pointers::default();
|
||||
assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true));
|
||||
assert!(next_page.take().is_empty());
|
||||
assert!(!next_page.captures_touch(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_modifiers_and_palm_cancellation_are_preserved() {
|
||||
reset();
|
||||
touch::set_source(Source::Pen);
|
||||
let mut p = Pointers::default();
|
||||
let pos = Pos2::new(10.0, 20.0);
|
||||
let mut finger = input("touch", 0);
|
||||
finger.id = 10;
|
||||
finger.modifiers.shift = true;
|
||||
assert!(p.feed("pointerdown", &finger, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift)));
|
||||
let pen = input("pen", 0);
|
||||
assert!(p.feed("pointerdown", &pen, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. })));
|
||||
assert!(p.fingers.is_empty());
|
||||
assert!(p.feed("pointermove", &finger, pos, &[], true));
|
||||
assert!(p.take().is_empty(), "a resting palm cannot move the pen");
|
||||
assert!(p.feed("pointerup", &pen, pos, &[], true));
|
||||
assert!(p.feed("pointerdown", &finger, pos, &[], true));
|
||||
assert!(p.feed("pointerup", &finger, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)));
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pen_buttons_are_not_reinterpreted_as_primary() {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true));
|
||||
assert!(p.take().is_empty());
|
||||
assert!(!touch::pen_seen());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
@@ -47,6 +48,8 @@ struct Host {
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
pasted: Rc<RefCell<Pasted>>,
|
||||
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
|
||||
pointers: SharedPointers,
|
||||
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
|
||||
/// restart keeps them, so the tab stays the owner of its recovery area.
|
||||
locks: Option<WebLocks>,
|
||||
@@ -73,6 +76,7 @@ pub fn start() {
|
||||
place_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
pasted: Rc::default(),
|
||||
pointers: SharedPointers::default(),
|
||||
locks,
|
||||
restarts: 0,
|
||||
};
|
||||
@@ -96,6 +100,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The app until the new runner takes it (still here if starting fails before).
|
||||
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
|
||||
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
|
||||
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
|
||||
let result = runner
|
||||
.start(
|
||||
canvas,
|
||||
@@ -127,6 +132,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
.await;
|
||||
match (result, kept) {
|
||||
(Ok(()), _) => {
|
||||
if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
|
||||
log::error!("vectorcraft-web: no pen input: {}", js_err(e));
|
||||
}
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
@@ -310,7 +318,7 @@ fn notice(lines: &[&str]) {
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
@@ -381,6 +389,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
raw.events.extend(self.host.pointers.borrow_mut().take());
|
||||
if let Some(app) = &mut self.app {
|
||||
app.raw_input_hook(raw);
|
||||
}
|
||||
@@ -397,6 +406,7 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
let open_inbox = inbox.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
let screen_color = eye_dropper(ctx.clone());
|
||||
Services {
|
||||
// File → Place…: the picked files go to the Place dialog.
|
||||
place_async: Some(Box::new(move || {
|
||||
@@ -434,11 +444,10 @@ 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}");
|
||||
}
|
||||
})),
|
||||
// Sample Color from Screen: the browser's EyeDropper, where it has one.
|
||||
screen_color,
|
||||
// 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.
|
||||
@@ -447,6 +456,41 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample Color from Screen in the browser: the EyeDropper API
|
||||
/// (<https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper>), which only some browsers have,
|
||||
/// on secure (HTTPS) pages; `None` without it, and the command is then gray. `open()` needs a
|
||||
/// transient user activation (otherwise it fails with a `NotAllowedError`) and resolves with the
|
||||
/// picked color's `sRGBHex`; Escape rejects it with an `AbortError`.
|
||||
fn eye_dropper(ctx: egui::Context) -> Option<vectorcraft_ui_egui::screen_color::StartSampler> {
|
||||
use js_sys::{Function, Promise, Reflect};
|
||||
use wasm_bindgen::JsValue;
|
||||
let window = web_sys::window()?;
|
||||
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
|
||||
Some(Box::new(move || {
|
||||
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(js_err)?;
|
||||
let open = Reflect::get(&eye, &JsValue::from_str("open")).map_err(js_err)?.dyn_into::<Function>().map_err(js_err)?;
|
||||
let promise = open.call0(&eye).map_err(js_err)?.dyn_into::<Promise>().map_err(js_err)?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let answer = match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(result) => Reflect::get(&result, &JsValue::from_str("sRGBHex"))
|
||||
.ok()
|
||||
.and_then(|hex| hex.as_string())
|
||||
.and_then(|hex| vectorcraft_ui_egui::screen_color::srgb_from_hex(&hex))
|
||||
.map(Some)
|
||||
.ok_or_else(|| "the browser's EyeDropper answered no color".to_string()),
|
||||
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|n| n.as_string()).as_deref() == Some("AbortError") => Ok(None),
|
||||
Err(e) => Err(js_err(e)),
|
||||
};
|
||||
// The UI may be gone (the page closing): nothing waits for the color then.
|
||||
let _ = tx.send(answer);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
Ok(rx)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
|
||||
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
|
||||
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
|
||||
@@ -517,7 +561,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -55,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", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "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"
|
||||
@@ -80,3 +80,7 @@ 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 }
|
||||
@@ -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();
|
||||
|
||||
+538
-12
@@ -37,9 +37,23 @@
|
||||
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
|
||||
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
|
||||
//! the app again without that adapter in the same way.
|
||||
//!
|
||||
//! A driver can also crash the whole process, and then no code in it runs to start it again:
|
||||
//! Intel's Direct3D 12 driver (`igd12umd64.dll`) ended 0.9.0 before its window appeared (#2243).
|
||||
//! So each start writes [`START_FILE`] in the log folder before the graphics start, adds the
|
||||
//! adapter once one is picked, and clears both once the first frame reaches the screen or the app
|
||||
//! ends without crashing ([`Startup::track`]). The next start that finds a start still recorded
|
||||
//! there leaves that adapter out, as a restart does, and keeps leaving it out; when the crash came
|
||||
//! while the adapters were being listed, before one was picked, it lists OpenGL's adapters alone
|
||||
//! (except on macOS, where wgpu has none). A start that `WGPU_BACKEND` or `WGPU_ADAPTER_NAME`
|
||||
//! chooses leaves nothing out, and the crashes are forgotten when the app version or
|
||||
//! Preferences › Performance › Graphics Processor changes, when a start shows its first frame on
|
||||
//! the adapter that crashed, or when no adapter is left to show the window.
|
||||
|
||||
use std::io::{Read as _, Seek as _, Write as _};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
|
||||
@@ -50,6 +64,14 @@ use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
|
||||
/// again.
|
||||
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
|
||||
|
||||
/// The file in the log folder that records each start of the window until its first frame
|
||||
/// reaches the screen, and the adapters earlier starts crashed on ([`Startup::track`]).
|
||||
pub const START_FILE: &str = "graphics-start.json";
|
||||
|
||||
/// The most bytes of [`START_FILE`] read, and the most crashed adapters it keeps.
|
||||
const START_FILE_LIMIT: u64 = 64 * 1024;
|
||||
const CRASHED_LIMIT: usize = 16;
|
||||
|
||||
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
|
||||
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
|
||||
|
||||
@@ -348,6 +370,226 @@ fn parse_skip(v: &str) -> Vec<String> {
|
||||
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// Whether `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` is set: then a start uses the backends and the
|
||||
/// adapter they name, and leaves out none that earlier starts crashed on.
|
||||
pub fn explicit_choice() -> bool {
|
||||
std::env::var_os("WGPU_BACKEND").is_some() || std::env::var_os(NAME_ENV).is_some()
|
||||
}
|
||||
|
||||
/// How the last start ended when it ended before the window's first frame reached the screen,
|
||||
/// with no error or panic to report: a crash in the graphics driver (#2243), or the process was
|
||||
/// ended from outside.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Crash {
|
||||
/// While the adapters were being listed, before one was picked.
|
||||
Listing,
|
||||
/// On the adapter with this [`key`] and name.
|
||||
Adapter { key: String, name: String },
|
||||
}
|
||||
|
||||
/// What a start leaves out because earlier starts crashed ([`Startup::track`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LeftOut {
|
||||
/// The adapters ([`key`]s) that crashed a start.
|
||||
pub adapters: Vec<String>,
|
||||
/// A start crashed while listing the adapters: list OpenGL's alone. Never on macOS, where wgpu
|
||||
/// has no OpenGL adapters to fall back to.
|
||||
pub opengl_only: bool,
|
||||
/// The crash the last start ended in, when this start leaves something out for it.
|
||||
pub crash: Option<Crash>,
|
||||
}
|
||||
|
||||
impl LeftOut {
|
||||
/// `backends` to list: OpenGL alone after a crash while listing, when it is among them;
|
||||
/// otherwise `backends`.
|
||||
pub fn backends(&self, backends: Backends) -> Backends {
|
||||
if self.opengl_only && backends.contains(Backends::GL) { Backends::GL } else { backends }
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter in [`START_FILE`]: its [`key`] and name.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct Recorded {
|
||||
key: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The start in progress, in [`START_FILE`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct Starting {
|
||||
/// The adapter picked; `None` while the adapters are being listed.
|
||||
adapter: Option<Recorded>,
|
||||
/// The listing found an adapter of a backend other than OpenGL.
|
||||
native: bool,
|
||||
}
|
||||
|
||||
/// What [`START_FILE`] holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct StartRecord {
|
||||
/// The app version and the Graphics Processor preference (`gpuPreference`) it was written
|
||||
/// under: the crashes count only for the same ones.
|
||||
version: String,
|
||||
preference: String,
|
||||
/// The start in progress, from before the graphics start until its first frame reaches the
|
||||
/// screen or the app ends without crashing.
|
||||
starting: Option<Starting>,
|
||||
/// The adapters that crashed a start, oldest first.
|
||||
crashed: Vec<Recorded>,
|
||||
/// A start crashed while listing the adapters.
|
||||
listing_crashed: bool,
|
||||
}
|
||||
|
||||
impl StartRecord {
|
||||
/// This start's record, made from the one the last start left (`previous`; `None` when there
|
||||
/// is none or it can't be read), and the crash that start ended in, if it did. A record
|
||||
/// written by another version or under another `preference` is dropped.
|
||||
fn begin(previous: Option<Self>, version: &str, preference: &str) -> (Self, Option<Crash>) {
|
||||
let mut record = previous.filter(|r| r.version == version && r.preference == preference).unwrap_or_default();
|
||||
let crash = record.starting.take().map(|s| match s.adapter {
|
||||
Some(Recorded { key, name }) => Crash::Adapter { key, name },
|
||||
None => Crash::Listing,
|
||||
});
|
||||
match &crash {
|
||||
Some(Crash::Adapter { key, name }) if !record.crashed.iter().any(|c| &c.key == key) => {
|
||||
record.crashed.push(Recorded { key: key.clone(), name: name.clone() });
|
||||
if record.crashed.len() > CRASHED_LIMIT {
|
||||
record.crashed.remove(0);
|
||||
}
|
||||
}
|
||||
Some(Crash::Listing) => record.listing_crashed = true,
|
||||
_ => {}
|
||||
}
|
||||
record.version = version.to_string();
|
||||
record.preference = preference.to_string();
|
||||
record.starting = Some(Starting::default());
|
||||
(record, crash)
|
||||
}
|
||||
|
||||
/// What this start leaves out: nothing when `explicit` ([`explicit_choice`]). `crash` is the
|
||||
/// one [`begin`](Self::begin) found.
|
||||
fn left_out(&self, crash: Option<Crash>, explicit: bool) -> LeftOut {
|
||||
if explicit {
|
||||
return LeftOut::default();
|
||||
}
|
||||
let opengl_only = self.listing_crashed && !cfg!(target_os = "macos");
|
||||
let crash = crash.filter(|c| matches!(c, Crash::Adapter { .. }) || opengl_only);
|
||||
LeftOut { adapters: self.crashed.iter().map(|c| c.key.clone()).collect(), opengl_only, crash }
|
||||
}
|
||||
|
||||
/// The adapter this start picked, after a listing that found one of a backend other than
|
||||
/// OpenGL when `native`.
|
||||
fn picked(&mut self, key: &str, name: &str, native: bool) {
|
||||
self.starting = Some(Starting { adapter: Some(Recorded { key: key.to_string(), name: name.trim().to_string() }), native });
|
||||
}
|
||||
|
||||
/// The listing found no adapter that can show the window: nothing is left to leave out, so the
|
||||
/// crashes are forgotten and the next start tries every adapter again.
|
||||
fn none_left(&mut self) {
|
||||
self.crashed.clear();
|
||||
self.listing_crashed = false;
|
||||
}
|
||||
|
||||
/// The window's first frame reached the screen: the adapter it was drawn on, and the listing
|
||||
/// when it found an adapter of a backend other than OpenGL, no longer count as crashed.
|
||||
fn presented(&mut self) {
|
||||
let Some(starting) = self.starting.take() else { return };
|
||||
if let Some(adapter) = starting.adapter {
|
||||
self.crashed.retain(|c| c.key != adapter.key);
|
||||
}
|
||||
if starting.native {
|
||||
self.listing_crashed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The start ended without crashing: a normal exit, an error, or a restart on another adapter.
|
||||
fn ended(&mut self) {
|
||||
self.starting = None;
|
||||
}
|
||||
|
||||
fn to_json(&self) -> String {
|
||||
let recorded = |r: &Recorded| serde_json::json!({ "key": r.key, "name": r.name });
|
||||
let mut v = serde_json::json!({
|
||||
"version": self.version,
|
||||
"gpuPreference": self.preference,
|
||||
"crashed": self.crashed.iter().map(recorded).collect::<Vec<_>>(),
|
||||
"listingCrashed": self.listing_crashed,
|
||||
});
|
||||
if let (Some(s), Some(map)) = (&self.starting, v.as_object_mut()) {
|
||||
map.insert("starting".into(), serde_json::json!({ "adapter": s.adapter.as_ref().map(recorded), "native": s.native }));
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// The record in `text`; `None` when it isn't one.
|
||||
fn parse(text: &str) -> Option<Self> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let recorded = |v: &serde_json::Value| -> Option<Recorded> {
|
||||
Some(Recorded { key: v.get("key")?.as_str()?.to_string(), name: v.get("name")?.as_str()?.to_string() })
|
||||
};
|
||||
let starting = match v.get("starting") {
|
||||
None => None,
|
||||
Some(s) => Some(Starting {
|
||||
adapter: match s.get("adapter") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(a) => Some(recorded(a)?),
|
||||
},
|
||||
native: s.get("native")?.as_bool()?,
|
||||
}),
|
||||
};
|
||||
let crashed = v.get("crashed")?.as_array()?;
|
||||
Some(Self {
|
||||
version: v.get("version")?.as_str()?.to_string(),
|
||||
preference: v.get("gpuPreference")?.as_str()?.to_string(),
|
||||
starting,
|
||||
crashed: crashed.iter().take(CRASHED_LIMIT).map(recorded).collect::<Option<_>>()?,
|
||||
listing_crashed: v.get("listingCrashed")?.as_bool()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`START_FILE`], open and locked while the start runs, so that a second copy of the app starting
|
||||
/// at the same time doesn't take it for the record of a start that crashed.
|
||||
struct StartFile {
|
||||
file: std::fs::File,
|
||||
record: StartRecord,
|
||||
}
|
||||
|
||||
impl StartFile {
|
||||
/// Open `dir`'s [`START_FILE`], with the record the last start left in it (`None` when there is
|
||||
/// none or it can't be read). `Ok(None)` when another copy of the app holds it.
|
||||
fn open(dir: &Path) -> std::io::Result<Option<(std::fs::File, Option<StartRecord>)>> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(START_FILE))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
|
||||
Err(std::fs::TryLockError::Error(e)) => return Err(e),
|
||||
}
|
||||
let mut text = String::new();
|
||||
// Text that isn't UTF-8, or is longer than any record, is ignored like a damaged record.
|
||||
let previous = (&file).take(START_FILE_LIMIT).read_to_string(&mut text).ok().and_then(|_| StartRecord::parse(&text));
|
||||
Ok(Some((file, previous)))
|
||||
}
|
||||
|
||||
fn write(&mut self) {
|
||||
let text = self.record.to_json();
|
||||
let written = self.file.set_len(0).and_then(|()| self.file.rewind()).and_then(|()| self.file.write_all(text.as_bytes()));
|
||||
if let Err(e) = written {
|
||||
log::warn!("{START_FILE}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `gpuPreference` the crashes are recorded under: any value this version doesn't
|
||||
/// know reads as `automatic`, as in [`power_preference`].
|
||||
fn preference_name(pref: Option<&str>) -> &'static str {
|
||||
match pref {
|
||||
Some("highPerformance") => "highPerformance",
|
||||
Some("lowPower") => "lowPower",
|
||||
_ => "automatic",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window's start-up leaves for [`finish`].
|
||||
#[derive(Default)]
|
||||
pub struct Startup {
|
||||
@@ -357,6 +599,8 @@ pub struct Startup {
|
||||
ui: OnceLock<egui::Context>,
|
||||
/// When the app was created, the files named at the start opened: the first frame is due.
|
||||
ready: OnceLock<Instant>,
|
||||
/// [`START_FILE`] while the start runs ([`Self::track`]).
|
||||
start: Mutex<Option<StartFile>>,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
@@ -371,6 +615,82 @@ impl Startup {
|
||||
// Set once, like `created`.
|
||||
let _ = self.ready.set(Instant::now());
|
||||
}
|
||||
|
||||
/// Record this start in `dir`'s [`START_FILE`] until the window's first frame reaches the
|
||||
/// screen or the app ends without crashing, and say what it leaves out for the starts that
|
||||
/// crashed before (see the module docs). `preference` is Preferences › Performance › Graphics
|
||||
/// Processor, and `explicit` is [`explicit_choice`]. When the file can't be used (it can't be
|
||||
/// written, or another copy of the app is starting), nothing is recorded or left out.
|
||||
pub fn track(&self, dir: &Path, preference: Option<&str>, explicit: bool) -> LeftOut {
|
||||
let (file, previous) = match StartFile::open(dir) {
|
||||
Ok(Some(opened)) => opened,
|
||||
Ok(None) => {
|
||||
log::info!("{START_FILE}: another VectorCraft is starting, so this start isn't recorded");
|
||||
return LeftOut::default();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{START_FILE}: {e}");
|
||||
return LeftOut::default();
|
||||
}
|
||||
};
|
||||
let (record, crash) = StartRecord::begin(previous, env!("CARGO_PKG_VERSION"), preference_name(preference));
|
||||
let left_out = record.left_out(crash.clone(), explicit);
|
||||
match &crash {
|
||||
Some(Crash::Adapter { key, name }) => {
|
||||
log::warn!("the last start ended on graphics adapter {name} ({key}) before the window's first frame reached the screen")
|
||||
}
|
||||
Some(Crash::Listing) => log::warn!("the last start ended while it listed the graphics adapters"),
|
||||
None => {}
|
||||
}
|
||||
if explicit && (!record.crashed.is_empty() || record.listing_crashed) {
|
||||
log::info!("WGPU_BACKEND or WGPU_ADAPTER_NAME is set: no adapter is left out for the starts that crashed");
|
||||
}
|
||||
if !left_out.adapters.is_empty() {
|
||||
log::warn!("leaving out the graphics adapters earlier starts crashed on: {}", left_out.adapters.join(", "));
|
||||
}
|
||||
if left_out.opengl_only {
|
||||
log::warn!("an earlier start crashed while it listed the graphics adapters: listing OpenGL's alone");
|
||||
}
|
||||
let mut file = StartFile { file, record };
|
||||
file.write();
|
||||
*self.start.lock().unwrap_or_else(PoisonError::into_inner) = Some(file);
|
||||
left_out
|
||||
}
|
||||
|
||||
/// [`selector`] picked the adapter with this [`key`] and name; `native` when the listing found
|
||||
/// an adapter of a backend other than OpenGL.
|
||||
fn picked(&self, key: &str, name: &str, native: bool) {
|
||||
// Set once; eframe picks the adapter once.
|
||||
let _ = self.adapter.set(key.to_string());
|
||||
self.update(|r| r.picked(key, name, native), false);
|
||||
}
|
||||
|
||||
/// [`selector`] found no adapter that can show the window.
|
||||
fn none_left(&self) {
|
||||
self.update(StartRecord::none_left, false);
|
||||
}
|
||||
|
||||
/// The window's first frame reached the screen, or the window started minimized.
|
||||
fn presented(&self) {
|
||||
self.update(StartRecord::presented, true);
|
||||
}
|
||||
|
||||
/// The app ends, or starts again, without crashing.
|
||||
fn ended(&self) {
|
||||
self.update(StartRecord::ended, true);
|
||||
}
|
||||
|
||||
/// Change the record and write it; `last` releases the file for the next start.
|
||||
fn update(&self, change: impl FnOnce(&mut StartRecord), last: bool) {
|
||||
let mut start = self.start.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(file) = start.as_mut() {
|
||||
change(&mut file.record);
|
||||
file.write();
|
||||
}
|
||||
if last {
|
||||
*start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`watch_first_frame`] makes of the window's start.
|
||||
@@ -396,10 +716,10 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the window's first frame from another thread: when it hasn't reached the screen
|
||||
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
|
||||
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
|
||||
/// thread is stuck in the graphics driver.
|
||||
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
|
||||
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
|
||||
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
|
||||
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
|
||||
pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let watch = move || loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
@@ -409,7 +729,10 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
|
||||
match first_frame(frames, due.elapsed(), minimized) {
|
||||
FirstFrame::Wait => continue,
|
||||
FirstFrame::Shown => return,
|
||||
FirstFrame::Shown => {
|
||||
startup.presented();
|
||||
return;
|
||||
}
|
||||
FirstFrame::Hung => {}
|
||||
}
|
||||
let mut skip = skipped();
|
||||
@@ -418,6 +741,8 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
|
||||
FIRST_FRAME_TIMEOUT.as_secs()
|
||||
);
|
||||
// The new app records its own start.
|
||||
startup.ended();
|
||||
skip.push(failed.to_string());
|
||||
match restart(&skip.join(",")) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
@@ -433,20 +758,22 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME`, the adapters a restart
|
||||
/// left out and those `crashed` ([`LeftOut::adapters`]). 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)>,
|
||||
crashed: Vec<String>,
|
||||
startup: Arc<Startup>,
|
||||
) -> NativeAdapterSelectorMethod {
|
||||
let named = std::env::var(NAME_ENV).ok();
|
||||
let skip = skipped();
|
||||
let left_out: Vec<String> = skip.iter().chain(&crashed).cloned().collect();
|
||||
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, compositor, named.as_deref(), &skip);
|
||||
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &left_out);
|
||||
let listed: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -455,6 +782,8 @@ pub fn selector(
|
||||
", can't show the window"
|
||||
} else if skip.contains(&c.key) {
|
||||
", failed before"
|
||||
} else if crashed.contains(&c.key) {
|
||||
", crashed an earlier start"
|
||||
} else if c.composites(compositor) {
|
||||
", the compositor draws with it"
|
||||
} else if drives_primary {
|
||||
@@ -470,6 +799,7 @@ pub fn selector(
|
||||
log::info!("graphics adapters: {}", listed.join("; "));
|
||||
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
|
||||
let Some((adapter, chosen)) = first else {
|
||||
startup.none_left();
|
||||
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
|
||||
};
|
||||
let why = match chosen.drives(&displays) {
|
||||
@@ -480,8 +810,7 @@ pub fn selector(
|
||||
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
|
||||
};
|
||||
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
|
||||
// Set once; eframe picks the adapter once.
|
||||
let _ = startup.adapter.set(chosen.key.clone());
|
||||
startup.picked(&chosen.key, &chosen.name, candidates.iter().any(|c| c.backend != Backend::Gl));
|
||||
Ok(adapter.clone())
|
||||
})
|
||||
}
|
||||
@@ -514,6 +843,8 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
|
||||
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
|
||||
/// ended it.
|
||||
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
|
||||
// Whatever follows, this start didn't crash; a new app records its own start.
|
||||
startup.ended();
|
||||
let (why, graphics_failed) = match outcome {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
|
||||
@@ -1101,4 +1432,199 @@ mod tests {
|
||||
GRAPHICS_PANIC.store(false, Ordering::Relaxed);
|
||||
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
|
||||
}
|
||||
|
||||
/// A log folder of its own for each test.
|
||||
fn start_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// What a start in `dir` leaves out; it then ends without crashing.
|
||||
fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut {
|
||||
let s = Startup::default();
|
||||
let left_out = s.track(dir, None, explicit);
|
||||
s.ended();
|
||||
left_out
|
||||
}
|
||||
|
||||
/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and
|
||||
/// Windows' software adapter.
|
||||
fn issue_2243() -> Vec<Candidate> {
|
||||
vec![
|
||||
pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)),
|
||||
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)),
|
||||
pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// #2243: the driver ends the process after the adapter was picked, before the first frame.
|
||||
/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after
|
||||
/// it, without a status-bar note.
|
||||
#[test]
|
||||
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
|
||||
let dir = start_dir("crash");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
assert_eq!(first.track(&dir, None, false), LeftOut::default());
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
// The process ends here: its file is closed and unlocked, and the start is still recorded.
|
||||
drop(first);
|
||||
|
||||
let second = Startup::default();
|
||||
let left_out = second.track(&dir, Some("automatic"), false);
|
||||
assert_eq!(left_out.adapters, ["Dx12:8086:3184"]);
|
||||
assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() }));
|
||||
assert!(!left_out.opengl_only);
|
||||
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL);
|
||||
let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters);
|
||||
assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter");
|
||||
second.picked(&c[2].key, &c[2].name, true);
|
||||
second.presented();
|
||||
|
||||
let third = Startup::default();
|
||||
let left_out = third.track(&dir, None, false);
|
||||
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
|
||||
assert_eq!(left_out.crash, None, "the note is shown once");
|
||||
third.ended();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
|
||||
/// an error, a restart on another adapter), leaves nothing out of the next.
|
||||
#[test]
|
||||
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
|
||||
let dir = start_dir("shown");
|
||||
let c = issue_2243();
|
||||
for end in [Startup::presented as fn(&Startup), Startup::ended] {
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
end(&first);
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
}
|
||||
// So does a start that ended before it picked an adapter.
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
assert!(finish(Ok(Ok(())), &first).is_ok());
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
|
||||
/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on
|
||||
/// macOS, where wgpu has none.
|
||||
#[test]
|
||||
fn a_crash_while_listing_lists_opengl_alone() {
|
||||
let dir = start_dir("listing");
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
drop(first);
|
||||
let second = Startup::default();
|
||||
let left_out = second.track(&dir, None, false);
|
||||
assert!(left_out.adapters.is_empty());
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(left_out, LeftOut::default());
|
||||
} else {
|
||||
assert_eq!(left_out.crash, Some(Crash::Listing));
|
||||
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL);
|
||||
assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL);
|
||||
assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to");
|
||||
}
|
||||
// Listing OpenGL alone found nothing that can show the window: the next start lists every
|
||||
// backend again.
|
||||
second.none_left();
|
||||
second.ended();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
|
||||
/// start shows its first frame on the adapter that crashed, the adapter counts again.
|
||||
#[test]
|
||||
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
|
||||
let dir = start_dir("explicit");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, true), LeftOut::default());
|
||||
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them");
|
||||
|
||||
let explicit = Startup::default();
|
||||
explicit.track(&dir, None, true);
|
||||
explicit.picked(&c[0].key, &c[0].name, true);
|
||||
explicit.presented();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
|
||||
/// are ignored: the start goes on with nothing left out.
|
||||
#[test]
|
||||
fn a_damaged_or_unreadable_start_file_is_ignored() {
|
||||
let dir = start_dir("damaged");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged: [&[u8]; 9] = [
|
||||
b"",
|
||||
b"{",
|
||||
b"[]",
|
||||
b"null",
|
||||
b"{\"version\":1}",
|
||||
b"{\"starting\":{}}",
|
||||
b"\xef\xbb\xbf\0",
|
||||
b"\xff\xfe{",
|
||||
&[b' '; START_FILE_LIMIT as usize + 1],
|
||||
];
|
||||
for text in damaged {
|
||||
std::fs::write(dir.join(START_FILE), text).unwrap();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)]));
|
||||
}
|
||||
|
||||
// Another copy of the app is starting: this start neither reads nor writes the file.
|
||||
let c = issue_2243();
|
||||
let starting = Startup::default();
|
||||
starting.track(&dir, None, false);
|
||||
starting.picked(&c[0].key, &c[0].name, true);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
drop(starting);
|
||||
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
|
||||
|
||||
// A folder in the file's place.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
|
||||
let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true };
|
||||
let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic");
|
||||
assert_eq!(crash, None);
|
||||
record.starting = Some(starting("Dx12:8086:3184"));
|
||||
let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic");
|
||||
assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() }));
|
||||
assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]);
|
||||
// Written and read back.
|
||||
assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone()));
|
||||
assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty());
|
||||
assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty());
|
||||
assert_eq!(preference_name(None), "automatic");
|
||||
assert_eq!(preference_name(Some("powerSaving")), "automatic");
|
||||
assert_eq!(preference_name(Some("lowPower")), "lowPower");
|
||||
// An adapter is listed once, and the list is bounded.
|
||||
let mut record = record;
|
||||
for n in 0..CRASHED_LIMIT + 4 {
|
||||
for _ in 0..2 {
|
||||
record.starting = Some(starting(&format!("Dx12:10de:{n:04x}")));
|
||||
record = StartRecord::begin(Some(record), "0.9.0", "automatic").0;
|
||||
}
|
||||
}
|
||||
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
|
||||
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
|
||||
//! every filter but doesn't show their names. The accessory uses the same filters and updates
|
||||
//! the filename, whose extension already determines the format in the save command.
|
||||
//!
|
||||
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
|
||||
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
|
||||
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
struct Ivars {
|
||||
panel: Weak<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
|
||||
#[unsafe(super = NSView)]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = Ivars]
|
||||
#[name = "VectorcraftSaveFormatAccessory"]
|
||||
struct FormatAccessory;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no additional requirements.
|
||||
unsafe impl NSObjectProtocol for FormatAccessory {}
|
||||
|
||||
impl FormatAccessory {
|
||||
// SAFETY: AppKit's target/action sends the popup as the single object argument.
|
||||
#[unsafe(method(chooseFormat:))]
|
||||
fn choose_format(&self, sender: &NSPopUpButton) {
|
||||
let Some(panel) = self.ivars().panel.load() else { return };
|
||||
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
|
||||
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
|
||||
let Some(extension) = extensions.first() else { return };
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
let new = filename(&name, extension, &self.ivars().filters);
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&new));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The suggested file's type, or the request's default (the first filter).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = Path::new(name).extension().and_then(|e| e.to_str());
|
||||
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return name.to_owned();
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
|
||||
{
|
||||
path.with_extension(extension).to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{name}.{extension}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
|
||||
/// without several named file types are left to rfd.
|
||||
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if filters.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Ok(handle) = window.window_handle() else { return };
|
||||
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
|
||||
// SAFETY: winit keeps its content view alive while this window reference is in use.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
|
||||
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
|
||||
for (name, _) in &accessory.ivars().filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
|
||||
}
|
||||
// The filter list comes from the built-in format registry, small enough for NSInteger.
|
||||
if let Ok(index) = isize::try_from(selected) {
|
||||
popup.selectItemAtIndex(index);
|
||||
}
|
||||
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
|
||||
// open. The selector above has exactly the target/action signature expected by NSControl.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
#[test]
|
||||
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
|
||||
for (name, expected) in
|
||||
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
|
||||
{
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! macOS: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the system's color
|
||||
//! sampler, `NSColorSampler` (macOS 10.15 and later), which "displays the system’s color-sampling
|
||||
//! interface and returns the selected color to your app"
|
||||
//! (<https://developer.apple.com/documentation/appkit/nscolorsampler>). AppKit calls the handler on
|
||||
//! the main thread with the color the person clicked, or with nil when they pressed Escape. The
|
||||
//! color is converted to sRGB before it is sent to the UI; AppKit clips a color outside sRGB to it
|
||||
//! (Display P3 red arrives as sRGB red).
|
||||
//!
|
||||
//! `showSamplerWithSelectionHandler:` has no safe binding; this module allows `unsafe_code`.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSColor, NSColorSampler, NSColorSpace};
|
||||
use vectorcraft_ui_egui::screen_color::{Answer, StartSampler};
|
||||
|
||||
/// The system's color sampler; `ui` is woken when it answers.
|
||||
pub fn sampler(ui: egui::Context) -> StartSampler {
|
||||
Box::new(move || {
|
||||
MainThreadMarker::new().ok_or("the color sampler can only be shown from the main thread")?;
|
||||
let (tx, rx) = channel();
|
||||
let ui = ui.clone();
|
||||
let handler = RcBlock::new(move |color: *mut NSColor| {
|
||||
// SAFETY: AppKit passes the selected color, or nil when the person canceled, and keeps
|
||||
// it alive while the handler runs.
|
||||
let answer: Answer = match unsafe { color.as_ref() } {
|
||||
None => Ok(None),
|
||||
Some(color) => srgb(color).map(Some).ok_or_else(|| "the sampled color has no sRGB value".into()),
|
||||
};
|
||||
// The UI may be gone (the app quitting): nothing waits for the color then.
|
||||
let _ = tx.send(answer);
|
||||
ui.request_repaint();
|
||||
});
|
||||
let sampler = NSColorSampler::new();
|
||||
// SAFETY: on the main thread (checked above); the handler has the signature AppKit calls it
|
||||
// with, and AppKit keeps the block and the sampler until the session ends.
|
||||
unsafe { sampler.showSamplerWithSelectionHandler(&handler) };
|
||||
Ok(rx)
|
||||
})
|
||||
}
|
||||
|
||||
/// The components of `color` in sRGB, or `None` when it has no RGB form (a pattern color).
|
||||
fn srgb(color: &NSColor) -> Option<[f32; 3]> {
|
||||
let c = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
|
||||
Some([c.redComponent() as f32, c.greenComponent() as f32, c.blueComponent() as f32])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A color in another space arrives as its sRGB components.
|
||||
#[test]
|
||||
fn sampled_colors_convert_to_srgb() {
|
||||
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
|
||||
assert_eq!(srgb(&red), Some([1.0, 0.0, 0.0]));
|
||||
// Display P3 (0.6, 0.4, 0.3) lies inside sRGB with other numbers: more red, less green and blue.
|
||||
let p3 = NSColor::colorWithDisplayP3Red_green_blue_alpha(0.6, 0.4, 0.3, 1.0);
|
||||
let [r, g, b] = srgb(&p3).unwrap();
|
||||
assert!(r > 0.61 && g < 0.39 && b < 0.29, "converted, not read as sRGB numbers: {r} {g} {b}");
|
||||
// A color outside sRGB is clipped to it.
|
||||
assert_eq!(srgb(&NSColor::colorWithDisplayP3Red_green_blue_alpha(1.0, 0.0, 0.0, 1.0)), Some([1.0, 0.0, 0.0]));
|
||||
let gray = NSColor::colorWithWhite_alpha(0.5, 1.0);
|
||||
let [r, g, b] = srgb(&gray).unwrap();
|
||||
assert!((r - g).abs() < 1e-4 && (g - b).abs() < 1e-4, "{r} {g} {b}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
|
||||
//! [--in-window-menus] [files…]`
|
||||
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! `--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`).
|
||||
@@ -35,7 +35,7 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
mod mac_screen_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -45,6 +45,7 @@ mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
@@ -62,6 +63,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 {
|
||||
@@ -72,6 +75,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);
|
||||
@@ -87,6 +95,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() {
|
||||
@@ -472,9 +485,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(pick) => {
|
||||
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();
|
||||
mac_save_format::attach(window, &pick);
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Folder => {
|
||||
@@ -489,7 +503,11 @@ 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);
|
||||
#[cfg(target_os = "macos")]
|
||||
let sampled_ui = ui.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
@@ -498,6 +516,7 @@ 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.
|
||||
@@ -520,6 +539,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
||||
open_file: Some(Box::new(open_file)),
|
||||
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
|
||||
// Sample Color from Screen: the system's color sampler.
|
||||
#[cfg(target_os = "macos")]
|
||||
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
|
||||
// File → Print: the system's printers and print queue.
|
||||
print: Some(Box::new(printing::SystemPrint)),
|
||||
..Default::default()
|
||||
@@ -603,6 +625,26 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
|
||||
const USAGE: &str = "\
|
||||
Usage: vectorcraft [options] [files…]
|
||||
|
||||
Opens the VectorCraft desktop app with the given files.
|
||||
|
||||
Options:
|
||||
--control <port> Start the localhost JSON-lines control server on <port>
|
||||
(or VECTORCRAFT_CONTROL_PORT)
|
||||
--automation-read-root <dir> Confine the files control requests read to <dir>
|
||||
--automation-write-root <dir> Confine the files control requests write to <dir>
|
||||
--in-window-menus Keep the menus inside the window on macOS
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
|
||||
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
vectorcraft-cli --help
|
||||
";
|
||||
|
||||
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.
|
||||
@@ -628,6 +670,14 @@ fn main() -> std::process::ExitCode {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
@@ -711,6 +761,16 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
// A driver that crashes the process while the window starts leaves this start recorded in the
|
||||
// log folder; the next start leaves that adapter out (#2243). Runs without preferences, which
|
||||
// write no log file, don't record their starts.
|
||||
#[cfg(feature = "wgpu")]
|
||||
let left_out = match log_dir().filter(|_| prefs_enabled()) {
|
||||
Some(dir) => startup.track(&dir, gpu_pref, gpu::explicit_choice()),
|
||||
None => gpu::LeftOut::default(),
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let crash = left_out.crash.clone();
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
@@ -740,13 +800,14 @@ 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, compositor, startup.clone()));
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, left_out.adapters.clone(), 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());
|
||||
create.instance_descriptor.backends =
|
||||
left_out.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.
|
||||
@@ -821,6 +882,15 @@ fn main() -> std::process::ExitCode {
|
||||
app.status(format!(
|
||||
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
|
||||
));
|
||||
} else if let Some(crash) = &crash {
|
||||
app.status(match crash {
|
||||
gpu::Crash::Adapter { name, .. } => format!(
|
||||
"The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
|
||||
),
|
||||
gpu::Crash::Listing => format!(
|
||||
"The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
|
||||
),
|
||||
});
|
||||
}
|
||||
app.graphics_adapter = Some(summary);
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
@@ -855,7 +925,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 }))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
|
||||
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included, and
|
||||
//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). The
|
||||
//! guard therefore also trims a parked maximize's rect back to the work area plus its own frame,
|
||||
//! clear of the taskbar, as PhotoCraft does (storytold/photocraft#3308). winit pins the client
|
||||
//! area to the work area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
|
||||
/// 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)?;
|
||||
reapply(&hwnd);
|
||||
Some(Box::new(move || reapply(&hwnd)))
|
||||
}
|
||||
|
||||
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
|
||||
/// maximize stays clear of the taskbar.
|
||||
fn reapply(hwnd: &HWND) {
|
||||
strip(hwnd);
|
||||
keep_clear_of_the_taskbar(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,
|
||||
);
|
||||
}
|
||||
|
||||
/// [`super::clamped_height`] for this frame's window: trim a maximized window whose rect runs
|
||||
/// under the taskbar back to the work area plus its frame. No-op otherwise.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
}
|
||||
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some(height) =
|
||||
super::clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | 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
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize border
|
||||
/// (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but
|
||||
/// being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it already
|
||||
/// does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at its maximize
|
||||
/// place. `monitor` is the monitor's full rect and `work` its work area (the monitor less the
|
||||
/// taskbar) in the same coordinates. The parked check is against the full monitor, because that is
|
||||
/// where Windows puts a caption-less maximize; the trim is against the work area. Platform-neutral
|
||||
/// so it is tested everywhere.
|
||||
#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))]
|
||||
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, _) = monitor;
|
||||
let (_, _, _, wb) = work;
|
||||
let side = ml - l;
|
||||
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
|
||||
return None;
|
||||
}
|
||||
if b <= wb + side {
|
||||
return None;
|
||||
}
|
||||
Some(wb + side - t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::clamped_height;
|
||||
|
||||
#[test]
|
||||
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
|
||||
// 1920 × 1080 with a 34 px taskbar: a caption-less maximize covers the whole monitor,
|
||||
// taskbar included (#1977). Trimmed so the rect ends at the work area plus the frame the
|
||||
// window was parked with.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
|
||||
// What Windows sizes a framed maximize to: the work area plus the frame.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
|
||||
// Taskbar on the left: the work area starts further right, but the monitor's bottom is
|
||||
// still clear, so the rect reaches nothing.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_away_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
|
||||
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Runs the desktop binary without a display, so a regression fails instead of opening a window.
|
||||
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
panic!("{args:?}: the app started instead of printing its usage");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
|
||||
let out = run(args, env);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
|
||||
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
|
||||
}
|
||||
// Headless work is the CLI's: the usage says where to go.
|
||||
assert!(stdout.contains("vectorcraft-cli"), "{args:?}: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_exits_before_startup() {
|
||||
prints_usage(&["--help"], &[]);
|
||||
prints_usage(&["-h"], &[]);
|
||||
// After a file or a flag, too: nothing is opened.
|
||||
prints_usage(&["drawing.svg", "--help"], &[]);
|
||||
prints_usage(&["--in-window-menus", "-h"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_a_bad_control_port_variable() {
|
||||
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5v14M20 5v14"/><path d="M4 12h16"/><path d="M7.5 9 4.5 12l3 3M16.5 9l3 3-3 3"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 5H20L14 12.5V18.5L10 20V12.5Z" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -93,3 +93,25 @@ 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
|
||||
|
||||
[people.osamaamin2011]
|
||||
real_name = "Osama Amin"
|
||||
display_name = "Osama Amin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.giang17]
|
||||
real_name = "Giang Nguyen"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.limackk]
|
||||
real_name = "Kamil Kwiatkowski"
|
||||
display_name = "Kamil Kwiatkowski"
|
||||
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 }
|
||||
@@ -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();
|
||||
|
||||
+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;
|
||||
|
||||
+90
-38
@@ -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));
|
||||
}
|
||||
|
||||
@@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() {
|
||||
let mut spacer = frame.clone();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,5 @@ image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
|
||||
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -651,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
|
||||
|
||||
+13
-4
@@ -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,
|
||||
_ => {}
|
||||
@@ -316,7 +320,12 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. } => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
@@ -502,7 +511,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
|
||||
|
||||
@@ -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();
|
||||
@@ -442,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())]))
|
||||
}
|
||||
|
||||
+116
-1
@@ -13,7 +13,7 @@ pub mod generic;
|
||||
pub mod icc;
|
||||
pub mod lab;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -165,6 +165,8 @@ fn generic() -> Arc<GenericCmyk> {
|
||||
}
|
||||
|
||||
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
|
||||
/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]).
|
||||
static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
|
||||
@@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
u.push(Arc::new(p));
|
||||
drop(u);
|
||||
clear_proof_cache();
|
||||
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
|
||||
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
|
||||
/// as long as the number stays the same.
|
||||
pub fn profiles_generation() -> u64 {
|
||||
PROFILES_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
|
||||
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
|
||||
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
|
||||
@@ -505,6 +515,7 @@ impl Cms {
|
||||
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
|
||||
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
|
||||
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
|
||||
ProofTarget::Tritanopia => TRITANOPIA.simulate(srgb),
|
||||
ProofTarget::WorkingCmyk => {
|
||||
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
|
||||
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
@@ -572,6 +583,106 @@ fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
|
||||
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
// Tritanopia: Brettel, Viénot & Mollon (1997), "Computerized simulation of color appearance for
|
||||
// dichromats", JOSA A 14(10), 2647-2655, https://doi.org/10.1364/JOSAA.14.002647. A color keeps its
|
||||
// L and M cone responses, and its S response is replaced so that it lies on one of two half-planes in
|
||||
// LMS space. Both half-planes contain the neutral axis (black to the display's white, so grays keep
|
||||
// their value); one passes through the 485 nm spectral color and the other through the 660 nm one. A
|
||||
// color uses the half-plane on its own side of the plane through the neutral axis and the S axis.
|
||||
// The matrices are computed at compile time from these inputs:
|
||||
// - linear sRGB → CIE XYZ: IEC 61966-2-1 (D65);
|
||||
// - CIE XYZ → LMS: Smith & Pokorny (1975), as printed in https://daltonlens.org/understanding-cvd-simulation/;
|
||||
// - the 485 nm and 660 nm colors: the Judd-Vos color matching functions,
|
||||
// http://www.cvrl.org/database/data/cmfs/ciexyzjv.csv.
|
||||
const LINEAR_SRGB_TO_XYZ: [[f64; 3]; 3] = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
|
||||
const XYZ_TO_LMS: [[f64; 3]; 3] = [[0.155_14, 0.543_12, -0.032_86], [-0.155_14, 0.456_84, 0.032_86], [0.0, 0.0, 0.016_08]];
|
||||
const XYZ_485_NM: [f64; 3] = [0.056_985, 0.169_87, 0.586_4];
|
||||
const XYZ_660_NM: [f64; 3] = [0.161_61, 0.061, 0.000_011_906];
|
||||
const TRITANOPIA: Dichromat = Dichromat::tritanope();
|
||||
|
||||
/// A dichromat simulation with two half-planes, as matrices on linear sRGB. `near` applies to colors
|
||||
/// whose dot product with `side` is 0 or more, and `far` applies to the others.
|
||||
struct Dichromat {
|
||||
side: [f32; 3],
|
||||
near: [[f32; 3]; 3],
|
||||
far: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
impl Dichromat {
|
||||
const fn tritanope() -> Self {
|
||||
let to_lms = mat3_mul(&XYZ_TO_LMS, &LINEAR_SRGB_TO_XYZ);
|
||||
let to_rgb = mat3_inverse(&to_lms);
|
||||
let white = mat3_apply(&to_lms, [1.0; 3]);
|
||||
let a485 = mat3_apply(&XYZ_TO_LMS, XYZ_485_NM);
|
||||
let a660 = mat3_apply(&XYZ_TO_LMS, XYZ_660_NM);
|
||||
let mut side = cross(white, [0.0, 0.0, 1.0]);
|
||||
if dot(side, a485) < 0.0 {
|
||||
side = [-side[0], -side[1], -side[2]];
|
||||
}
|
||||
// A color on the 485 nm side lands on that half-plane.
|
||||
let near = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a485)), &to_lms));
|
||||
let far = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a660)), &to_lms));
|
||||
// `side · lms(rgb)` is `(to_lmsᵀ · side) · rgb`.
|
||||
let t = &to_lms;
|
||||
let side_rgb = [
|
||||
t[0][0] * side[0] + t[1][0] * side[1] + t[2][0] * side[2],
|
||||
t[0][1] * side[0] + t[1][1] * side[1] + t[2][1] * side[2],
|
||||
t[0][2] * side[0] + t[1][2] * side[1] + t[2][2] * side[2],
|
||||
];
|
||||
Dichromat { side: vec3_f32(side_rgb), near: mat3_f32(&near), far: mat3_f32(&far) }
|
||||
}
|
||||
|
||||
fn simulate(&self, srgb: [f32; 3]) -> [f32; 3] {
|
||||
let rgb = lab::srgb_linear(srgb);
|
||||
let s = self.side;
|
||||
let m = if s[0] * rgb[0] + s[1] * rgb[1] + s[2] * rgb[2] >= 0.0 { &self.near } else { &self.far };
|
||||
lab::mat_mul(m, rgb).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// LMS → LMS: keeps L and M and sets S so the result lies in the plane with normal `n`.
|
||||
const fn keep_lm_on_plane(n: [f64; 3]) -> [[f64; 3]; 3] {
|
||||
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-n[0] / n[2], -n[1] / n[2], 0.0]]
|
||||
}
|
||||
|
||||
const fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
const fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
const fn mat3_apply(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
|
||||
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
|
||||
}
|
||||
|
||||
const fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
let mut i = 0;
|
||||
while i < 3 {
|
||||
out[i] = [dot(a[i], [b[0][0], b[1][0], b[2][0]]), dot(a[i], [b[0][1], b[1][1], b[2][1]]), dot(a[i], [b[0][2], b[1][2], b[2][2]])];
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const fn mat3_inverse(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let c0 = cross(m[1], m[2]);
|
||||
let c1 = cross(m[2], m[0]);
|
||||
let c2 = cross(m[0], m[1]);
|
||||
let det = dot(m[0], c0);
|
||||
[[c0[0] / det, c1[0] / det, c2[0] / det], [c0[1] / det, c1[1] / det, c2[1] / det], [c0[2] / det, c1[2] / det, c2[2] / det]]
|
||||
}
|
||||
|
||||
const fn vec3_f32(v: [f64; 3]) -> [f32; 3] {
|
||||
[v[0] as f32, v[1] as f32, v[2] as f32]
|
||||
}
|
||||
|
||||
const fn mat3_f32(m: &[[f64; 3]; 3]) -> [[f32; 3]; 3] {
|
||||
[vec3_f32(m[0]), vec3_f32(m[1]), vec3_f32(m[2])]
|
||||
}
|
||||
|
||||
/// View → Proof Setup target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
|
||||
@@ -591,6 +702,8 @@ pub enum ProofTarget {
|
||||
Protanopia,
|
||||
/// Colour blindness — deuteranopia type.
|
||||
Deuteranopia,
|
||||
/// Color blindness, tritanopia type.
|
||||
Tritanopia,
|
||||
}
|
||||
|
||||
impl ProofTarget {
|
||||
@@ -605,6 +718,7 @@ impl ProofTarget {
|
||||
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
|
||||
"protanopia" => ProofTarget::Protanopia,
|
||||
"deuteranopia" => ProofTarget::Deuteranopia,
|
||||
"tritanopia" => ProofTarget::Tritanopia,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -617,6 +731,7 @@ impl ProofTarget {
|
||||
ProofTarget::MonitorRgb => "monitorRgb".into(),
|
||||
ProofTarget::Protanopia => "protanopia".into(),
|
||||
ProofTarget::Deuteranopia => "deuteranopia".into(),
|
||||
ProofTarget::Tritanopia => "tritanopia".into(),
|
||||
}
|
||||
}
|
||||
/// Whether the target simulates a CMYK press (separations make sense).
|
||||
|
||||
@@ -194,6 +194,35 @@ fn colour_blindness_proofs() {
|
||||
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
|
||||
}
|
||||
|
||||
/// The expected colors were computed separately with NumPy from the same published inputs (linear
|
||||
/// sRGB to XYZ from IEC 61966-2-1, Smith & Pokorny's XYZ to LMS, Judd-Vos XYZ at 485 nm and 660 nm),
|
||||
/// following Brettel, Viénot & Mollon (1997) with the display's white as the neutral axis.
|
||||
#[test]
|
||||
fn tritanopia_proof_follows_brettel_1997() {
|
||||
let c = cms();
|
||||
let t = ProofSetup { target: ProofTarget::Tritanopia, ..Default::default() };
|
||||
assert_eq!(ProofTarget::parse("tritanopia"), Some(ProofTarget::Tritanopia));
|
||||
assert_eq!(ProofTarget::Tritanopia.id(), "tritanopia");
|
||||
for g in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let out = c.proof_srgb([g; 3], &t);
|
||||
assert!(out.iter().all(|v| (v - g).abs() < 1e-4), "gray {g} stays gray: {out:?}");
|
||||
}
|
||||
let cases = [
|
||||
// The 660 nm half-plane.
|
||||
([1.0, 0.0, 0.0], [1.0, 0.0, 0.299_231]),
|
||||
([1.0, 0.0, 1.0], [0.934_206, 0.388_351, 0.467_178]),
|
||||
([0.9, 0.6, 0.1], [0.926_825, 0.557_667, 0.599_649]),
|
||||
// The 485 nm half-plane.
|
||||
([0.0, 0.0, 1.0], [0.0, 0.377_268, 0.519_933]),
|
||||
([0.0, 1.0, 0.0], [0.480_503, 0.920_568, 1.0]),
|
||||
([0.2, 0.4, 0.8], [0.0, 0.464_73, 0.563_516]),
|
||||
];
|
||||
for (rgb, want) in cases {
|
||||
let got = c.proof_srgb(rgb, &t);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rgb:?}: {got:?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_icc_rejected() {
|
||||
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,23 @@ impl RowMap {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new
|
||||
/// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the
|
||||
/// method makes it something else (a shade, or another hue).
|
||||
pub fn tint(&self, c: Color) -> Option<f32> {
|
||||
match self.method {
|
||||
Method::Exact => Some(1.0),
|
||||
Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)),
|
||||
Method::PreserveTints => Some(1.0),
|
||||
Method::ScaleTints => Some(tint_of(c, self.key)),
|
||||
Method::TintsShades => {
|
||||
let d = c.to_lab().l - self.mean_l;
|
||||
(d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3))
|
||||
}
|
||||
Method::HueShift => (c == self.key).then_some(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The new colour of current colour `c`, in `c`'s model.
|
||||
pub fn map(&self, c: Color) -> Color {
|
||||
let to = keep_model(c, self.to);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
|
||||
use crate::{Document, NodeId, Unit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
/// What a dimension measures.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DimensionMode {
|
||||
#[default]
|
||||
Linear,
|
||||
Angular,
|
||||
Radius,
|
||||
Diameter,
|
||||
}
|
||||
|
||||
impl DimensionMode {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Linear => "linear",
|
||||
Self::Angular => "angular",
|
||||
Self::Radius => "radius",
|
||||
Self::Diameter => "diameter",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit a length is labelled in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LengthUnit {
|
||||
#[default]
|
||||
Pt,
|
||||
Pc,
|
||||
Px,
|
||||
In,
|
||||
Mm,
|
||||
Cm,
|
||||
M,
|
||||
Ft,
|
||||
Yd,
|
||||
}
|
||||
|
||||
impl LengthUnit {
|
||||
pub const ALL: [LengthUnit; 9] = [
|
||||
LengthUnit::In,
|
||||
LengthUnit::Mm,
|
||||
LengthUnit::Pt,
|
||||
LengthUnit::Pc,
|
||||
LengthUnit::Px,
|
||||
LengthUnit::Cm,
|
||||
LengthUnit::M,
|
||||
LengthUnit::Ft,
|
||||
LengthUnit::Yd,
|
||||
];
|
||||
pub fn unit(self) -> Unit {
|
||||
match self {
|
||||
Self::Pt => Unit::Points,
|
||||
Self::Pc => Unit::Picas,
|
||||
Self::Px => Unit::Pixels,
|
||||
Self::In => Unit::Inches,
|
||||
Self::Mm => Unit::Millimeters,
|
||||
Self::Cm => Unit::Centimeters,
|
||||
Self::M => Unit::Meters,
|
||||
Self::Ft => Unit::Feet,
|
||||
Self::Yd => Unit::Yards,
|
||||
}
|
||||
}
|
||||
/// The suffix written after a length.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pt => "pt",
|
||||
Self::Pc => "pc",
|
||||
Self::Px => "px",
|
||||
Self::In => "in",
|
||||
Self::Mm => "mm",
|
||||
Self::Cm => "cm",
|
||||
Self::M => "m",
|
||||
Self::Ft => "ft",
|
||||
Self::Yd => "yd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LineType {
|
||||
#[default]
|
||||
Solid,
|
||||
Dashed,
|
||||
Dotted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArrowStyle {
|
||||
#[default]
|
||||
Filled,
|
||||
Open,
|
||||
Tick,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where the label sits across the dimension line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextPosition {
|
||||
/// On the side of the dimension line away from the measured object.
|
||||
#[default]
|
||||
Away,
|
||||
/// Between the dimension line and the measured object.
|
||||
Between,
|
||||
}
|
||||
|
||||
/// Where the label sits along the dimension line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextAlignment {
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimensionOptions {
|
||||
pub units: LengthUnit,
|
||||
pub precision: usize,
|
||||
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
|
||||
pub scale: String,
|
||||
pub hide_units: bool,
|
||||
pub line_weight: f64,
|
||||
pub line_type: LineType,
|
||||
pub line_color: [u8; 3],
|
||||
pub arrow_style: ArrowStyle,
|
||||
pub arrow_scale: f64,
|
||||
pub extension_weight: f64,
|
||||
pub extension_type: LineType,
|
||||
pub extension_color: [u8; 3],
|
||||
pub extension_offset: f64,
|
||||
pub extension_length: f64,
|
||||
pub hide_extensions: bool,
|
||||
pub font_family: String,
|
||||
pub font_style: String,
|
||||
pub font_size: f64,
|
||||
pub text_color: [u8; 3],
|
||||
pub text_position: TextPosition,
|
||||
pub text_alignment: TextAlignment,
|
||||
pub text_offset: f64,
|
||||
}
|
||||
|
||||
impl Default for DimensionOptions {
|
||||
fn default() -> Self {
|
||||
// The document's own type defaults: a font every build has, black, in points.
|
||||
let type_style = crate::CharStyle::default();
|
||||
Self {
|
||||
units: LengthUnit::Pt,
|
||||
precision: 2,
|
||||
scale: "1:1".into(),
|
||||
hide_units: false,
|
||||
line_weight: 0.5,
|
||||
line_type: LineType::Solid,
|
||||
line_color: [0, 0, 0],
|
||||
arrow_style: ArrowStyle::Filled,
|
||||
arrow_scale: 100.0,
|
||||
extension_weight: 0.5,
|
||||
extension_type: LineType::Solid,
|
||||
extension_color: [0, 0, 0],
|
||||
extension_offset: 4.0,
|
||||
extension_length: 6.0,
|
||||
hide_extensions: false,
|
||||
font_family: type_style.font_family,
|
||||
font_style: type_style.font_style,
|
||||
font_size: type_style.size,
|
||||
text_color: [0, 0, 0],
|
||||
text_position: TextPosition::Away,
|
||||
text_alignment: TextAlignment::Center,
|
||||
text_offset: 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DimensionOptions {
|
||||
/// Options given to a command or a tool: unknown keys are refused there, while files load
|
||||
/// tolerantly so a newer version's options don't stop a document opening.
|
||||
pub fn from_params(v: serde_json::Value) -> Result<Self, String> {
|
||||
if let (Some(given), Ok(serde_json::Value::Object(known))) = (v.as_object(), serde_json::to_value(Self::default()))
|
||||
&& let Some(k) = given.keys().find(|k| !known.contains_key(*k))
|
||||
{
|
||||
return Err(format!("Unknown dimension option {k}"));
|
||||
}
|
||||
let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||||
o.validate()?;
|
||||
Ok(o)
|
||||
}
|
||||
pub fn factor(&self) -> Result<f64, String> {
|
||||
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
|
||||
let parse = |s: &str| s.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
|
||||
let a = parse(a).ok_or("Invalid drawing scale")?;
|
||||
let b = parse(b).ok_or("Invalid real scale")?;
|
||||
let f = b / a;
|
||||
if !(1e-6..=1e6).contains(&f) {
|
||||
return Err("Scale ratio must be between 1:1000000 and 1000000:1".into());
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.factor()?;
|
||||
if self.precision > 6 {
|
||||
return Err("Choose a precision from 0 to 6".into());
|
||||
}
|
||||
for (v, min, max) in [
|
||||
(self.line_weight, 0.1, 100.0),
|
||||
(self.extension_weight, 0.1, 100.0),
|
||||
(self.arrow_scale, 1.0, 1000.0),
|
||||
(self.font_size, 1.0, 1000.0),
|
||||
(self.extension_offset, 0.0, 10000.0),
|
||||
(self.extension_length, 0.0, 10000.0),
|
||||
(self.text_offset, 0.0, 10000.0),
|
||||
] {
|
||||
if !v.is_finite() || v < min || v > max {
|
||||
return Err("Dimension size is outside its supported range".into());
|
||||
}
|
||||
}
|
||||
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
|
||||
return Err("Invalid dimension font name".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `value` in points for a length, in degrees for an angle.
|
||||
pub fn label(&self, value: f64, angle: bool) -> Result<String, String> {
|
||||
self.validate()?;
|
||||
let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
|
||||
if !value.is_finite() {
|
||||
return Err("Non-finite dimension".into());
|
||||
}
|
||||
let suffix = if self.hide_units {
|
||||
""
|
||||
} else if angle {
|
||||
"°"
|
||||
} else {
|
||||
self.units.suffix()
|
||||
};
|
||||
let sep = if suffix.is_empty() || angle { "" } else { " " };
|
||||
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SegmentRef {
|
||||
pub node: NodeId,
|
||||
pub subpath: usize,
|
||||
pub segment: usize,
|
||||
}
|
||||
|
||||
impl SegmentRef {
|
||||
/// The segment's two end points.
|
||||
pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
|
||||
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
|
||||
if self.segment >= sp.segment_count() {
|
||||
return None;
|
||||
}
|
||||
let seg = sp.segment(self.segment);
|
||||
Some((seg.p0, seg.p3))
|
||||
}
|
||||
/// Whether the segment is a straight line.
|
||||
pub fn is_straight(&self, doc: &Document) -> bool {
|
||||
doc.node(self.node)
|
||||
.and_then(|n| n.path_data())
|
||||
.and_then(|p| p.subpaths.get(self.subpath))
|
||||
.is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum DimensionSource {
|
||||
Segment {
|
||||
edge: SegmentRef,
|
||||
},
|
||||
Angle {
|
||||
first: SegmentRef,
|
||||
second: SegmentRef,
|
||||
},
|
||||
Circle {
|
||||
node: NodeId,
|
||||
},
|
||||
/// One segment that is a perfect arc.
|
||||
Arc {
|
||||
edge: SegmentRef,
|
||||
},
|
||||
Points {
|
||||
a: Option<PointRef>,
|
||||
b: Option<PointRef>,
|
||||
vertex: Option<PointRef>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DimensionSource {
|
||||
pub fn nodes(&self) -> Vec<NodeId> {
|
||||
match self {
|
||||
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
|
||||
Self::Angle { first, second } => vec![first.node, second.node],
|
||||
Self::Circle { node } => vec![*node],
|
||||
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
|
||||
}
|
||||
}
|
||||
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
|
||||
/// edits keep their topology and remain linked.
|
||||
pub fn topology_changed(&self, before: &Document, after: &Document) -> bool {
|
||||
if matches!(self, Self::Circle { .. }) {
|
||||
return false;
|
||||
}
|
||||
self.nodes().iter().any(|id| {
|
||||
let counts = |doc: &Document| {
|
||||
doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::<Vec<_>>())
|
||||
};
|
||||
let old = counts(before);
|
||||
old.is_some() && old != counts(after)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PointRef {
|
||||
pub node: NodeId,
|
||||
pub subpath: usize,
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
impl PointRef {
|
||||
pub fn position(&self, doc: &Document) -> Option<Point> {
|
||||
Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
pub mode: DimensionMode,
|
||||
pub a: Point,
|
||||
pub b: Point,
|
||||
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
|
||||
pub vertex: Point,
|
||||
pub offset: f64,
|
||||
pub length: f64,
|
||||
pub options: DimensionOptions,
|
||||
pub source: Option<DimensionSource>,
|
||||
/// How far a linked dimension was moved on its own since the last refresh: the refresh turns
|
||||
/// it into a new placement unless the measured art moved too.
|
||||
#[serde(skip)]
|
||||
pub moved: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
/// A point on the drawn dimension line (linear), arc (angular) or radius line.
|
||||
pub fn handle(&self) -> Point {
|
||||
match self.mode {
|
||||
DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
|
||||
DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
|
||||
DimensionMode::Radius | DimensionMode::Diameter => self.b,
|
||||
}
|
||||
}
|
||||
/// The left normal of a → b.
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
let u = self.b - self.a;
|
||||
let len = u.hypot();
|
||||
if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
|
||||
}
|
||||
fn bisector(&self) -> Vec2 {
|
||||
let start = (self.a - self.vertex).atan2();
|
||||
Vec2::from_angle(start + self.angle() * 0.5)
|
||||
}
|
||||
/// Places the dimension through `p`: the line's distance from the art (linear), the arc's
|
||||
/// radius (angular) or the label's direction (radial).
|
||||
pub fn place(&mut self, p: Point) {
|
||||
match self.mode {
|
||||
DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
|
||||
DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
|
||||
DimensionMode::Radius | DimensionMode::Diameter => {
|
||||
if p.distance(self.a) > 1e-6 {
|
||||
let r = self.a.distance(self.b);
|
||||
let u = p - self.a;
|
||||
self.b = self.a + u / u.hypot() * r;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, xf: Affine) {
|
||||
if self.source.is_some() {
|
||||
// The art it measures decides where its ends are: record the move, and the refresh
|
||||
// after this edit turns it into a new placement unless the art moved with it.
|
||||
let at = self.handle();
|
||||
self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
self.a = xf * self.a;
|
||||
self.b = xf * self.b;
|
||||
self.vertex = xf * self.vertex;
|
||||
if self.mode == DimensionMode::Linear {
|
||||
self.length = self.a.distance(self.b);
|
||||
}
|
||||
}
|
||||
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
|
||||
match &self.source {
|
||||
Some(DimensionSource::Points { a, b, vertex }) => {
|
||||
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
|
||||
if let Some(r) = reference {
|
||||
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
|
||||
}
|
||||
}
|
||||
self.length = self.a.distance(self.b);
|
||||
}
|
||||
Some(DimensionSource::Segment { edge }) => {
|
||||
let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
|
||||
self.a = a;
|
||||
self.b = b;
|
||||
self.length = a.distance(b);
|
||||
}
|
||||
Some(DimensionSource::Angle { first, second }) => {
|
||||
for edge in [first, second] {
|
||||
if !edge.is_straight(doc) {
|
||||
return Err("Angles require two straight segments".into());
|
||||
}
|
||||
}
|
||||
let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
|
||||
let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
|
||||
let u = b - a;
|
||||
let v = d - c;
|
||||
let den = u.cross(v);
|
||||
if den.abs() < 1e-9 {
|
||||
return Err("Choose two non-parallel straight segments".into());
|
||||
}
|
||||
let p = a + u * ((c - a).cross(v) / den);
|
||||
self.vertex = p;
|
||||
self.a = if a.distance(p) > b.distance(p) { a } else { b };
|
||||
self.b = if c.distance(p) > d.distance(p) { c } else { d };
|
||||
}
|
||||
Some(DimensionSource::Circle { node }) => {
|
||||
let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?;
|
||||
self.set_radial(center, radius);
|
||||
}
|
||||
Some(DimensionSource::Arc { edge }) => {
|
||||
let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
|
||||
self.set_radial(center, radius);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
fn set_radial(&mut self, center: Point, radius: f64) {
|
||||
let direction = self.b - self.a;
|
||||
let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
|
||||
self.a = center;
|
||||
self.b = center + dir * radius;
|
||||
self.length = radius;
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.options.validate()?;
|
||||
for p in [self.a, self.b, self.vertex] {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 {
|
||||
return Err("Dimension point is outside the canvas".into());
|
||||
}
|
||||
}
|
||||
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
|
||||
return Err("Invalid dimension distance".into());
|
||||
}
|
||||
if self.a.distance(self.b) < 1e-6 {
|
||||
return Err("Choose two different points".into());
|
||||
}
|
||||
if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
|
||||
return Err("Choose two nonzero angle arms".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn angle(&self) -> f64 {
|
||||
let u = self.a - self.vertex;
|
||||
let v = self.b - self.vertex;
|
||||
u.cross(v).atan2(u.dot(v))
|
||||
}
|
||||
pub fn label(&self) -> Result<String, String> {
|
||||
let value = match self.mode {
|
||||
DimensionMode::Angular => self.angle().abs().to_degrees(),
|
||||
DimensionMode::Radius => self.a.distance(self.b),
|
||||
DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
|
||||
DimensionMode::Linear => self.a.distance(self.b),
|
||||
};
|
||||
self.options.label(value, self.mode == DimensionMode::Angular)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes.
|
||||
pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let path = doc.node(id)?.path_data()?;
|
||||
let sp = path.subpaths.first()?;
|
||||
if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) {
|
||||
return None;
|
||||
}
|
||||
let bounds = path.bounds()?;
|
||||
let center = bounds.center();
|
||||
let radius = bounds.width() * 0.5;
|
||||
if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
for i in 0..sp.segment_count() {
|
||||
for t in [0.0, 0.25, 0.5, 0.75] {
|
||||
if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((center, radius))
|
||||
}
|
||||
|
||||
/// A curved segment that follows one circle (a perfect arc): its centre and radius.
|
||||
pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
|
||||
if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
|
||||
return None;
|
||||
}
|
||||
let seg = sp.segment(edge.segment);
|
||||
let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
|
||||
// The circle through the ends and the middle of the segment.
|
||||
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
|
||||
if d.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let sq = |a: Point| a.x * a.x + a.y * a.y;
|
||||
let center = Point::new(
|
||||
(sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
|
||||
(sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
|
||||
);
|
||||
let radius = center.distance(p);
|
||||
if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
|
||||
return None;
|
||||
}
|
||||
for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
|
||||
if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((center, radius))
|
||||
}
|
||||
|
||||
/// The two straight segments that meet at a corner anchor, if both are straight.
|
||||
pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
|
||||
let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
|
||||
let count = sp.segment_count();
|
||||
let n = sp.anchors.len();
|
||||
if count < 2 || anchor >= n {
|
||||
return None;
|
||||
}
|
||||
// Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
|
||||
let before = if anchor > 0 {
|
||||
anchor - 1
|
||||
} else if sp.closed {
|
||||
count - 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let after = anchor;
|
||||
if after >= count {
|
||||
return None;
|
||||
}
|
||||
let first = SegmentRef { node, subpath, segment: before };
|
||||
let second = SegmentRef { node, subpath, segment: after };
|
||||
(first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
|
||||
}
|
||||
@@ -422,6 +422,32 @@ impl GraphSpec {
|
||||
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
|
||||
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
|
||||
}
|
||||
|
||||
/// CSV of this graph's data (header row with series names, then category + values), as Graph
|
||||
/// Data and `graph.setData` use. Always writes a header so a blank first category isn't
|
||||
/// misread as one.
|
||||
pub fn to_csv(&self) -> String {
|
||||
let cells = self.cells();
|
||||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
|
||||
let mut out = String::from(",");
|
||||
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !self.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
|
||||
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
|
||||
out.push_str(&format!("\"{c}\""));
|
||||
} else {
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphSpec {
|
||||
|
||||
+312
-43
@@ -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 {
|
||||
@@ -83,6 +85,79 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
/// 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 { .. } | NodeKind::Intertwine { .. } => 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), true).map(|h| h.top_object(scope)) {
|
||||
@@ -94,13 +169,15 @@ 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).
|
||||
/// [`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 editable_only
|
||||
@@ -109,12 +186,12 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
&& let Some(mut chain) = doc.ancestry(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
|
||||
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, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
|
||||
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() {
|
||||
@@ -127,7 +204,7 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
}
|
||||
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, editable_only) {
|
||||
if let Some(h) = hit_children(layer, p, opt, &symbols, skip, &mut chain, editable_only) {
|
||||
return Some(finish(h));
|
||||
}
|
||||
chain.pop();
|
||||
@@ -150,10 +227,29 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -166,37 +262,25 @@ fn hit_children(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.extras().shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !(edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
for i in hit_order(parent, children, p) {
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
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, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => {
|
||||
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, editable_only),
|
||||
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, editable_only)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
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;
|
||||
@@ -206,6 +290,23 @@ fn hit_children(
|
||||
None
|
||||
}
|
||||
|
||||
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
|
||||
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
|
||||
/// come first (from the top among them too).
|
||||
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
|
||||
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
|
||||
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
|
||||
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let rest = (0..children.len()).rev().filter({
|
||||
let over = over.clone();
|
||||
move |i| !over.contains(i)
|
||||
});
|
||||
over.into_iter().chain(rest)
|
||||
}
|
||||
|
||||
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
|
||||
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
|
||||
/// outline within the tolerance. Outline mode hits the outline only.
|
||||
@@ -216,7 +317,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();
|
||||
@@ -246,8 +347,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) {
|
||||
@@ -282,14 +383,15 @@ 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)) {
|
||||
@@ -320,38 +422,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);
|
||||
}
|
||||
}
|
||||
@@ -619,6 +727,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);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Object › Intertwine: objects that pass over and under each other.
|
||||
//!
|
||||
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
|
||||
//! stacking order. A member can be drawn over the other members in part of the group: that area
|
||||
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
|
||||
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
|
||||
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
|
||||
//! over the others in one place, the upper one in the stacking order ends on top.
|
||||
//!
|
||||
//! The members stay editable, and Release gives them back as they were (the areas let go).
|
||||
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
|
||||
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
|
||||
pub fn over_area(n: &Node) -> Option<&PathData> {
|
||||
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
|
||||
}
|
||||
|
||||
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
|
||||
/// member with an area drawn over the others, as a clip group of that area around a copy of it
|
||||
/// (ids 0: generated art).
|
||||
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
|
||||
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
|
||||
for c in children {
|
||||
let Some(area) = over_area(c) else { continue };
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
|
||||
clip.appearance = crate::Appearance::default().into();
|
||||
let mut copy = plain(c);
|
||||
zero_ids(&mut copy);
|
||||
out.push(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `n` without its Intertwine area (the expansion is plain art).
|
||||
fn plain(n: &Node) -> Node {
|
||||
let mut n = n.clone();
|
||||
if n.extras().intertwine.is_some() {
|
||||
n.extras_mut().intertwine = None;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn zero_ids(n: &mut Node) {
|
||||
n.id = NodeId(0);
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
zero_ids(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(id: u64, x: f64) -> Arc<Node> {
|
||||
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), crate::Appearance::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_without_areas_expand_to_themselves() {
|
||||
let kids = vec![square(1, 0.0), square(2, 5.0)];
|
||||
let out = expand(&kids);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out.iter().map(|n| n.id).collect::<Vec<_>>(), [NodeId(1), NodeId(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_with_an_area_is_drawn_again_clipped_to_it_on_top() {
|
||||
let mut a = (*square(1, 0.0)).clone();
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
|
||||
let kids = vec![Arc::new(a), square(2, 5.0)];
|
||||
let out = expand(&kids);
|
||||
assert_eq!(out.len(), 3, "both members, then the raised copy");
|
||||
assert!(out.iter().all(|n| n.extras().intertwine.is_none()), "the expansion is plain art");
|
||||
let NodeKind::Group { children, clip: true } = &out[2].kind else { panic!("a clip group: {:?}", out[2].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert_eq!(children[1].path_data(), kids[0].path_data());
|
||||
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_member_is_not_drawn_over_the_others() {
|
||||
let mut a = (*square(1, 0.0)).clone();
|
||||
a.visible = false;
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
|
||||
assert_eq!(expand(&[Arc::new(a), square(2, 5.0)]).len(), 2);
|
||||
}
|
||||
}
|
||||
+211
-30
@@ -11,14 +11,19 @@ pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod dimension;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
pub mod shared;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
@@ -30,6 +35,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;
|
||||
@@ -43,9 +49,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;
|
||||
@@ -76,6 +85,7 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::MAX_DEPTH;
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
@@ -113,6 +123,9 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
/// Coordinates beyond this many points exceed the supported document range.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("no such object {0}")]
|
||||
@@ -226,7 +239,10 @@ impl Unit {
|
||||
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
|
||||
///
|
||||
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
|
||||
/// math typed before a field's unit suffix works.
|
||||
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
|
||||
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
|
||||
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
|
||||
/// percentage on its own (`50%`) reads as nothing.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
@@ -263,20 +279,41 @@ impl Unit {
|
||||
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
|
||||
for op in ['+', '-', '*', '/'] {
|
||||
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
|
||||
// of its operators (left to right).
|
||||
for ops in [&['+', '-'][..], &['*', '/'][..]] {
|
||||
// Past the first character, so a leading sign isn't an operator.
|
||||
let skip = s.chars().next().map_or(0, char::len_utf8);
|
||||
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
|
||||
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
|
||||
let is_sign = |i: usize| {
|
||||
s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-'))
|
||||
&& s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c))
|
||||
};
|
||||
let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i));
|
||||
if let Some(i) = found.map(|(i, _)| i) {
|
||||
let (l, r) = (&s[..i], &s[i + 1..]);
|
||||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(op) = s[i..].chars().next() else { continue };
|
||||
return match op {
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
'+' | '-' => {
|
||||
let a = parse_measure(l, default)?;
|
||||
// A percentage added or taken away is a share of everything before it:
|
||||
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
|
||||
let b = match percent(r) {
|
||||
Some(k) => a * k,
|
||||
None => parse_measure(r, default)?,
|
||||
};
|
||||
Some(if op == '+' { a + b } else { a - b })
|
||||
}
|
||||
_ => {
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
|
||||
// a unit after it is the expression's.
|
||||
let (k, unit) = match percent(r) {
|
||||
Some(k) => (k, None),
|
||||
None => number_unit(r)?,
|
||||
};
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
@@ -296,7 +333,10 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
&& !s.ends_with("pt")
|
||||
&& !s.ends_with("px")
|
||||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
// The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points).
|
||||
let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 };
|
||||
let picas = p.trim().parse::<f64>().ok()?.abs();
|
||||
return Some(sign * (picas * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0)));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
@@ -323,6 +363,14 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
|
||||
fn percent(s: &str) -> Option<f64> {
|
||||
match number_unit(s.trim().strip_suffix('%')?)? {
|
||||
(p, None) => Some(p / 100.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
|
||||
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
|
||||
pub fn parse_number(s: &str) -> Option<f64> {
|
||||
@@ -680,6 +728,10 @@ pub struct Document {
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
/// is set, so documents without any pay nothing for them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dimensions: bool,
|
||||
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
|
||||
#[serde(skip)]
|
||||
index: id_index::IdIndex,
|
||||
@@ -700,6 +752,13 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
let origin = self.ruler_origin;
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
|
||||
}
|
||||
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
@@ -760,6 +819,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -770,7 +830,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 {
|
||||
@@ -778,14 +840,22 @@ 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.
|
||||
@@ -1207,33 +1277,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> {
|
||||
// Art drawn flush with the artboard can end up a rounding error outside it (a background
|
||||
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
|
||||
let rect = rect.inflate(1e-6, 1e-6);
|
||||
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||||
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
|
||||
@@ -1245,9 +1318,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
|
||||
}
|
||||
@@ -1367,7 +1441,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)
|
||||
@@ -1379,6 +1453,13 @@ impl Document {
|
||||
pub fn reid(&mut self, node: &Node) -> Node {
|
||||
let mut n = node.clone();
|
||||
n.id = self.alloc_id();
|
||||
// A copied annotation must never bind its source ids to unrelated art in a different
|
||||
// document. Keep the measured geometry; new measurements can be linked after pasting.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(dimension) = &mut e.dimension
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
@@ -1451,7 +1532,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 {
|
||||
@@ -1473,6 +1554,30 @@ impl Document {
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Symbol Options › Registration for `name` (default: centre).
|
||||
pub fn symbol_registration(&self, name: &str) -> SymbolRegistration {
|
||||
self.unknown
|
||||
.get(SYMBOL_REGISTRATION)
|
||||
.and_then(|m| m.get(name))
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(SymbolRegistration::from_u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when centre) the registration point stored for `name`.
|
||||
pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) {
|
||||
let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
if reg.is_center() {
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_REGISTRATION);
|
||||
}
|
||||
} else {
|
||||
o.insert(name.into(), serde_json::json!(reg.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1490,11 +1595,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1562,6 +1685,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -1608,6 +1742,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();
|
||||
@@ -1680,6 +1853,14 @@ mod tests {
|
||||
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
|
||||
assert_eq!(Unit::Inches.format(144.0), "2 in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_picas_and_points_sign_applies_to_whole_value() {
|
||||
assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0));
|
||||
assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0));
|
||||
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
|
||||
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+65
-13
@@ -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)
|
||||
@@ -1431,7 +1437,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.map(w.f),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
if let Some(d) = &deeper {
|
||||
return map_node(&expanded_group_hooks(n, w.hooks), d);
|
||||
}
|
||||
@@ -1610,7 +1616,15 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
|
||||
/// Is this one of the live kinds?
|
||||
pub fn is_live(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
|
||||
matches!(
|
||||
n.kind,
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
|
||||
@@ -1645,6 +1659,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
NodeKind::PlacedDocument(p) => p.art(n),
|
||||
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
|
||||
_ => vec![n.clone()],
|
||||
}
|
||||
}
|
||||
@@ -1699,11 +1714,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
|
||||
}
|
||||
@@ -1711,6 +1738,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> {
|
||||
|
||||
@@ -251,10 +251,14 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
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 += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
}
|
||||
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::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Intertwine { 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,
|
||||
|
||||
+187
-26
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes};
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
@@ -485,6 +486,12 @@ pub enum NodeKind {
|
||||
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
|
||||
/// [`crate::placed_document`]).
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
|
||||
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
|
||||
/// [`crate::intertwine`]).
|
||||
Intertwine {
|
||||
children: Vec<Arc<Node>>,
|
||||
},
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -542,6 +549,9 @@ pub struct NodeExtras {
|
||||
/// Index of the graph series this generated group belongs to. Axes, the legend and other
|
||||
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
|
||||
pub series_index: Option<u32>,
|
||||
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
|
||||
/// the placed gradients in it with the outlines.
|
||||
pub type_outlines: bool,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
@@ -554,6 +564,12 @@ pub struct NodeExtras {
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// Object › Perspective: the perspective grid plane the object is attached to.
|
||||
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
/// Dimension object: the live measurement it draws; its ordinary group children hold its
|
||||
/// exportable appearance (Object › Dimensions, the Dimension tool).
|
||||
pub dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -563,11 +579,14 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
wrap: None,
|
||||
graph: None,
|
||||
series_index: None,
|
||||
type_outlines: false,
|
||||
shaper: None,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -691,6 +710,7 @@ impl Serialize for Node {
|
||||
e.wrap.is_none(),
|
||||
e.graph.is_none(),
|
||||
e.series_index.is_none(),
|
||||
is_default(&e.type_outlines),
|
||||
e.shaper.is_none(),
|
||||
false,
|
||||
e.graphic_style.is_none(),
|
||||
@@ -698,6 +718,8 @@ impl Serialize for Node {
|
||||
e.slice.is_none(),
|
||||
is_default(&self.bbox_angle),
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -725,6 +747,7 @@ impl Serialize for Node {
|
||||
field!("wrap", &e.wrap);
|
||||
field!("graph", &e.graph);
|
||||
field!("series_index", &e.series_index);
|
||||
field!("type_outlines", &e.type_outlines);
|
||||
field!("shaper", &e.shaper);
|
||||
field!("kind", &self.kind);
|
||||
field!("graphic_style", &e.graphic_style);
|
||||
@@ -732,6 +755,8 @@ impl Serialize for Node {
|
||||
field!("slice", &e.slice);
|
||||
field!("bbox_angle", &self.bbox_angle);
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -771,6 +796,8 @@ struct NodeWire {
|
||||
#[serde(default)]
|
||||
series_index: Option<u32>,
|
||||
#[serde(default)]
|
||||
type_outlines: bool,
|
||||
#[serde(default)]
|
||||
shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
kind: NodeKind,
|
||||
#[serde(default)]
|
||||
@@ -783,6 +810,10 @@ struct NodeWire {
|
||||
bbox_angle: f64,
|
||||
#[serde(default)]
|
||||
perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
#[serde(default)]
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -793,11 +824,14 @@ impl From<NodeWire> for Node {
|
||||
wrap: w.wrap,
|
||||
graph: w.graph,
|
||||
series_index: w.series_index,
|
||||
type_outlines: w.type_outlines,
|
||||
shaper: w.shaper,
|
||||
graphic_style: w.graphic_style,
|
||||
attrs: w.attrs,
|
||||
slice: w.slice,
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -842,6 +876,38 @@ impl OpacityMask {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity
|
||||
/// mask's, whose luminance over the black outside the art is then the art's alpha.
|
||||
pub fn whiten(n: &mut Node) {
|
||||
let white = |p: &mut Paint| match p {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
}
|
||||
Paint::None | Paint::Pattern { .. } => {}
|
||||
};
|
||||
for item in &mut n.appearance.items {
|
||||
white(item.paint_mut());
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
|
||||
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
|
||||
white(&mut r.style.fill);
|
||||
white(&mut r.style.stroke);
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch {
|
||||
whiten(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new(id: NodeId, kind: NodeKind) -> Self {
|
||||
Self {
|
||||
@@ -883,7 +949,8 @@ impl Node {
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. })
|
||||
| NodeKind::Intertwine { children } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -894,7 +961,8 @@ impl Node {
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. })
|
||||
| NodeKind::Intertwine { children } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -962,6 +1030,7 @@ impl Node {
|
||||
NodeKind::Mesh(_) => "Mesh",
|
||||
NodeKind::Repeat(r) => r.kind.label(),
|
||||
NodeKind::PlacedDocument(_) => "Placed Document",
|
||||
NodeKind::Intertwine { .. } => "Intertwine",
|
||||
}
|
||||
}
|
||||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||||
@@ -977,6 +1046,12 @@ impl Node {
|
||||
_ => format!("<{}>", self.kind_label()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID.
|
||||
pub fn identify_label(&self, xml_id: bool) -> String {
|
||||
let name = self.display_name();
|
||||
if xml_id { crate::xml_id::from_object_name(&name) } else { name }
|
||||
}
|
||||
/// Path data for path nodes.
|
||||
pub fn path_data(&self) -> Option<&PathData> {
|
||||
match &self.kind {
|
||||
@@ -1004,79 +1079,147 @@ impl Node {
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape.
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
|
||||
/// measured as in [`Node::visual_bounds`].
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
|
||||
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
|
||||
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
|
||||
/// grown by the farthest a stroke in the art can reach.
|
||||
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
symbols.quickly(|| self.reach(Some(symbols)))
|
||||
}
|
||||
/// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given.
|
||||
pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => self.visual_bounds(),
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
|
||||
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A
|
||||
/// symbol instance is measured as its transform applied to the 20 × 20 square around the origin
|
||||
/// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures
|
||||
/// its art).
|
||||
pub fn geometric_bounds(&self) -> Option<Rect> {
|
||||
self.geometric(None)
|
||||
}
|
||||
/// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to
|
||||
/// its symbol's art box from `symbols`. An instance is measured as the square when its symbol
|
||||
/// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and
|
||||
/// when symbols nest 32 deep in its art.
|
||||
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
self.geometric(Some(symbols))
|
||||
}
|
||||
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
|
||||
/// is given.
|
||||
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
let union = |nodes: &[Arc<Node>]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols)));
|
||||
match &self.kind {
|
||||
NodeKind::Path { path, .. } => path.bounds(),
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
children.first().and_then(|c| c.geometric_bounds())
|
||||
children.first().and_then(|c| c.geometric(symbols))
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
|
||||
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = crate::live::nodes_bounds(children);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric);
|
||||
self.projected(Some(xf.transform_rect_bbox(art)))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
|
||||
// copies out by the source's bounds measured without the document.
|
||||
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
|
||||
NodeKind::PlacedDocument(p) => Some(p.bounds()),
|
||||
// The members drawn over the others are drawn where they are.
|
||||
NodeKind::Intertwine { children } => union(children),
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
|
||||
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
|
||||
/// ([`Node::visual_bounds_with`] measures its art).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
self.visual(None)
|
||||
}
|
||||
/// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from
|
||||
/// `symbols`, the strokes in it included. An instance that only moves its art is measured as
|
||||
/// the art's visual box moved. Any other instance is measured as its art transformed by it with
|
||||
/// the strokes at their own weight, as the renderer draws it, and never smaller than its
|
||||
/// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as
|
||||
/// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of
|
||||
/// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In
|
||||
/// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's
|
||||
/// geometric box placed by its transform, grown by its own strokes.
|
||||
pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
self.visual(Some(symbols))
|
||||
}
|
||||
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
|
||||
/// given.
|
||||
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
|
||||
let clip = children.first()?;
|
||||
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
|
||||
clip.visual_bounds()
|
||||
clip.visual(symbols)
|
||||
} else {
|
||||
clip.geometric_bounds()
|
||||
clip.geometric(symbols)
|
||||
}
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
|
||||
let o = crate::live::max_outset(children);
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
|
||||
let o = crate::live::max_outset(children, symbols);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
|
||||
}
|
||||
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
|
||||
let o = crate::live::max_outset(content);
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
let o = crate::live::max_outset(content, symbols);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
let b = self.geometric_bounds()?;
|
||||
let b = self.geometric(symbols)?;
|
||||
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
|
||||
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
|
||||
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
|
||||
Some((s, art)) => {
|
||||
let placed = xf.transform_rect_bbox(art.geometric);
|
||||
match s.placed_visual(symbol, *xf) {
|
||||
Some(v) => v.union(placed),
|
||||
None => placed.inflate(art.reach, art.reach),
|
||||
}
|
||||
}
|
||||
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
|
||||
};
|
||||
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1087,6 +1230,12 @@ impl Node {
|
||||
self.transform_scaled(a, &scaling.into());
|
||||
}
|
||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||||
// A dimension's measurement moves with it.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(dimension) = &mut e.dimension
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -1125,7 +1274,10 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
NodeKind::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Intertwine { children } => {
|
||||
for c in children.iter_mut() {
|
||||
Arc::make_mut(c).transform_scaled(a, sc);
|
||||
}
|
||||
@@ -1179,6 +1331,10 @@ impl Node {
|
||||
{
|
||||
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
|
||||
}
|
||||
// The area where an Intertwine member is drawn over the others moves with it.
|
||||
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
|
||||
area.transform(a);
|
||||
}
|
||||
}
|
||||
/// Visit this node and all descendants depth first (paint order).
|
||||
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
|
||||
@@ -1307,7 +1463,7 @@ impl Node {
|
||||
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
|
||||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
@@ -1722,6 +1878,9 @@ mod kind_de {
|
||||
Mesh(GradientMesh),
|
||||
Repeat(RepeatSpec),
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
Intertwine {
|
||||
children: Vec<Arc<Node>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NodeKind {
|
||||
@@ -1851,6 +2010,7 @@ mod tests {
|
||||
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
|
||||
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
|
||||
e.series_index = Some(2);
|
||||
e.type_outlines = true;
|
||||
e.graphic_style = Some(3);
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
@@ -1889,6 +2049,7 @@ mod tests {
|
||||
"mask",
|
||||
"trace",
|
||||
"series_index",
|
||||
"type_outlines",
|
||||
"kind",
|
||||
"graphic_style",
|
||||
"attrs",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -516,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>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
|
||||
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
|
||||
Arc::ptr_eq(&a.0, &b.0)
|
||||
}
|
||||
/// A weak reference to the value: it keeps the allocation, so the value's address names it
|
||||
/// while the reference is kept, but not the value.
|
||||
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
|
||||
Arc::downgrade(&this.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Shared<T> {
|
||||
|
||||
@@ -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,11 +153,12 @@ 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)));
|
||||
@@ -224,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(),
|
||||
_ => &[],
|
||||
@@ -233,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 [],
|
||||
@@ -280,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,36 @@
|
||||
//! Symbol Options › Registration: the 9-point grid (0 = top-left … 4 = centre … 8 = bottom-right)
|
||||
//! that `symbol.place` uses as the point that lands on the click.
|
||||
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
/// Document unknown key: `{ symbolName: 0..8 }` for each symbol's registration point.
|
||||
pub const SYMBOL_REGISTRATION: &str = "symbolRegistration";
|
||||
|
||||
/// Index on the 9-point grid (row-major, 4 = centre).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SymbolRegistration(pub u8);
|
||||
|
||||
impl Default for SymbolRegistration {
|
||||
fn default() -> Self {
|
||||
Self(4)
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolRegistration {
|
||||
pub const CENTER: Self = Self(4);
|
||||
|
||||
pub fn from_u64(v: u64) -> Option<Self> {
|
||||
(v <= 8).then_some(Self(v as u8))
|
||||
}
|
||||
|
||||
pub fn is_center(self) -> bool {
|
||||
self.0 == 4
|
||||
}
|
||||
|
||||
/// Offset of this registration point from the symbol's centre, in natural-size units
|
||||
/// (right +, down +).
|
||||
pub fn offset_from_center(self, w: f64, h: f64) -> Vec2 {
|
||||
let (col, row) = (self.0 % 3, self.0 / 3);
|
||||
Vec2::new((f64::from(col) - 1.0) * (w * 0.5), (f64::from(row) - 1.0) * (h * 0.5))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
|
||||
//! and canvas readouts.
|
||||
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit,
|
||||
//! percentages in arithmetic, names and canvas readouts.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::Unit;
|
||||
|
||||
@@ -34,13 +36,54 @@ fn typed_units_win_over_the_fields_unit() {
|
||||
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percentage_is_a_share_of_the_value_before_it() {
|
||||
let (mm, cm) = (72.0 / 25.4, 72.0 / 2.54);
|
||||
for (u, s, want) in [
|
||||
(Unit::Centimeters, "3cm*50%", 1.5 * cm),
|
||||
(Unit::Points, "50pt+25%", 62.5),
|
||||
(Unit::Points, "50pt-25%", 37.5),
|
||||
(Unit::Millimeters, "10*50%", 5.0 * mm),
|
||||
(Unit::Centimeters, "3cm/50%", 6.0 * cm),
|
||||
(Unit::Points, "50 pt + 25 %", 62.5),
|
||||
(Unit::Points, "10+20+50%", 45.0),
|
||||
// A percentage added or taken away is a share of everything before it. A percentage factor
|
||||
// is a share of the term before it.
|
||||
(Unit::Points, "100pt+20pt-50%", 60.0),
|
||||
(Unit::Points, "100pt-20pt+50%", 120.0),
|
||||
(Unit::Points, "100pt+20%-10pt", 110.0),
|
||||
(Unit::Points, "10+20*50%", 20.0),
|
||||
(Unit::Points, "10*2+50%", 30.0),
|
||||
(Unit::Points, "10*2/50%", 40.0),
|
||||
(Unit::Points, "10+-50%", 5.0),
|
||||
(Unit::Points, "50pt--25%", 62.5),
|
||||
(Unit::Centimeters, "3cm*-50%", -1.5 * cm),
|
||||
] {
|
||||
let got = u.parse(s).unwrap_or_else(|| panic!("{u:?} can't read `{s}`"));
|
||||
assert!((got - want).abs() < 1e-9, "{u:?}: `{s}` → {got}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_text_reads_as_nothing_without_panicking() {
|
||||
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
|
||||
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+", "50%", "%", "5%%", "%5", "1/0%", "10+%", "10*5pt%"] {
|
||||
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
|
||||
/// or as nothing and does not panic.
|
||||
#[test]
|
||||
fn arithmetic_text_reads_as_a_finite_value_or_nothing(s in "[0-9.+*/% ptxinmcfyd-]{0,24}") {
|
||||
for u in Unit::ALL {
|
||||
if let Some(v) = u.parse(&s) {
|
||||
prop_assert!(v.is_finite(), "{u:?}: `{s}` → {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_are_named_by_label_key_or_suffix() {
|
||||
for u in Unit::ALL {
|
||||
@@ -66,3 +109,23 @@ fn readouts_show_two_decimals() {
|
||||
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
|
||||
assert_eq!(Unit::Points.number(-0.00001), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_operand_after_an_operator_is_a_sign() {
|
||||
assert_eq!(Unit::Points.parse("10*-2"), Some(-20.0));
|
||||
assert_eq!(Unit::Points.parse("-10/-2"), Some(5.0));
|
||||
assert_eq!(Unit::Points.parse("5--3"), Some(8.0));
|
||||
assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0));
|
||||
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
|
||||
assert_eq!(Unit::Points.parse("10-2"), Some(8.0));
|
||||
assert_eq!(super::parse_number("90*-1"), Some(-90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_mul_div() {
|
||||
// `*` and `/` share one precedence level and run left to right.
|
||||
assert_eq!(Unit::Points.parse("10/2*5"), Some(25.0));
|
||||
assert_eq!(crate::parse_number("100*3/4"), Some(75.0));
|
||||
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
|
||||
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
|
||||
}
|
||||
@@ -734,7 +734,9 @@ impl PathAlign {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AreaOptions {
|
||||
#[serde(deserialize_with = "de_rows_or_columns")]
|
||||
pub rows: usize,
|
||||
#[serde(deserialize_with = "de_rows_or_columns")]
|
||||
pub columns: usize,
|
||||
/// Gutter between rows/columns in points.
|
||||
pub gutter: f64,
|
||||
@@ -765,6 +767,18 @@ impl Default for AreaOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl AreaOptions {
|
||||
/// The most rows, and the most columns, area type can have.
|
||||
pub const MAX_ROWS_COLUMNS: usize = 100;
|
||||
}
|
||||
|
||||
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
|
||||
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
|
||||
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
|
||||
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
|
||||
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
|
||||
}
|
||||
|
||||
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
|
||||
/// `{"shrinkText": {"minPercent": 50}}`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+35
-12
@@ -17,15 +17,21 @@ pub enum VariableKind {
|
||||
Text,
|
||||
/// Whether bound objects show.
|
||||
Visibility,
|
||||
/// A linked image's file path (the pixels swap; the object's bounds stay).
|
||||
Image,
|
||||
/// A graph object's data (CSV of categories and series, as Graph Data uses).
|
||||
Graph,
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both), `image` or `graph`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
"visibility" | "visible" => Some(Self::Visibility),
|
||||
"image" => Some(Self::Image),
|
||||
"graph" => Some(Self::Graph),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +41,8 @@ impl VariableKind {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Visibility => "visibility",
|
||||
Self::Image => "image",
|
||||
Self::Graph => "graph",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,13 +60,23 @@ pub struct Variable {
|
||||
pub enum DataValue {
|
||||
Text(String),
|
||||
Visible(bool),
|
||||
/// Absolute path of the image file an image variable should show.
|
||||
Image(String),
|
||||
/// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`).
|
||||
Graph(String),
|
||||
}
|
||||
|
||||
impl DataValue {
|
||||
/// Whether this value fits the variable's kind (a dataset may carry values for
|
||||
/// variables of either kind; each binding takes what fits it).
|
||||
pub fn fits(&self, kind: VariableKind) -> bool {
|
||||
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
||||
matches!(
|
||||
(self, kind),
|
||||
(Self::Text(_), VariableKind::Text)
|
||||
| (Self::Visible(_), VariableKind::Visibility)
|
||||
| (Self::Image(_), VariableKind::Image)
|
||||
| (Self::Graph(_), VariableKind::Graph)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +102,10 @@ impl DataSet {
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
||||
/// defined, which is what makes two text variables on one object deterministic: the last one
|
||||
/// wins, and `applied` counts both.
|
||||
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
|
||||
/// variables are defined, which is what makes two text variables on one object deterministic:
|
||||
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
|
||||
/// last one defined wins too.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -173,9 +192,10 @@ impl Document {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
||||
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
||||
/// or locked layer (the document's value is whatever it was left at).
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
|
||||
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
|
||||
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
|
||||
/// document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
return None;
|
||||
@@ -184,14 +204,17 @@ impl Document {
|
||||
match (kind, &node.kind) {
|
||||
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
||||
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
||||
(VariableKind::Text, _) => None,
|
||||
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
|
||||
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
|
||||
(VariableKind::Text | VariableKind::Image, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The values a dataset captured right now: what each bound object holds, in the order the
|
||||
/// variables are defined. `dataset.capture` writes these into a new row and
|
||||
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
||||
/// them to tell whether the art still matches what it says.
|
||||
/// `dataset.update` into the active one, for the variables that row holds (`all: true` adds
|
||||
/// the rest); the Variables panel compares the active row with them to tell whether the art
|
||||
/// still matches what it says.
|
||||
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
||||
let mut out = BTreeMap::new();
|
||||
for v in &self.variables.variables {
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
+532
-9
@@ -9,18 +9,26 @@
|
||||
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
|
||||
//! become images of their own).
|
||||
//!
|
||||
//! Type that Transform effects on it, or on its group or layer, only move and copy stays type in
|
||||
//! each place when its characters are in solid colors and the copies do not overlap ([`moved_type`]
|
||||
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_doc::color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
|
||||
|
||||
use crate::distort::{transform_move, transform_offsets};
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, needs_outline, reshape,
|
||||
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -98,8 +106,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -107,9 +119,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
|
||||
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
|
||||
if let Some(m) = evaluate_container(n) {
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
|
||||
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
|
||||
// fills' and strokes' art) get ids of their own.
|
||||
if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
@@ -159,6 +172,201 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
|
||||
}
|
||||
}
|
||||
|
||||
/// Most copies of one type object that stay type, the original included ([`moved_type`]).
|
||||
const MAX_TYPE_COPIES: usize = 10_000;
|
||||
/// Most bytes that the copies of one type object that stay type take together in the document's
|
||||
/// JSON form, the original included ([`moved_type`]): its text, frame or path, wrap shapes and the
|
||||
/// rest of the object, once per copy.
|
||||
const MAX_TYPE_COPY_BYTES: usize = 20_000_000;
|
||||
|
||||
/// The params of geometry effects `fx` when there are some and they are all Transform effects that
|
||||
/// only move ([`transform_move`]), in order; `None` otherwise.
|
||||
fn transform_moves(fx: &[&Effect]) -> Option<Vec<serde_json::Value>> {
|
||||
if fx.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fx.iter().map(|e| (e.id == "distort.transform").then(|| merged_params(&e.id, &e.params)).filter(|p| transform_move(p).is_some())).collect()
|
||||
}
|
||||
|
||||
/// Type `n` in each place that Transform effects that only move ([`transform_moves`]) copy its
|
||||
/// glyph outlines to: `n` moved there, the original's place first. `None` for other objects; for
|
||||
/// type that is hidden, in perspective, has effects, transparency, an opacity mask or fills and
|
||||
/// strokes of its own (beyond its characters'), has characters painted with a gradient or pattern,
|
||||
/// underlined or struck through, has character strokes and is scaled or sheared, holds inline
|
||||
/// graphics or has no glyphs; when the effects would stop copies of its outlines early
|
||||
/// ([`transform_offsets`]); when the copies would pass [`MAX_TYPE_COPIES`] or
|
||||
/// [`MAX_TYPE_COPY_BYTES`]; and when the copies overlap: what one copy paints (its
|
||||
/// character strokes included) reaches into another's box.
|
||||
fn moved_type(n: &Node, moves: &[serde_json::Value]) -> Option<Vec<Node>> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let flat = |p: &Paint| matches!(p, Paint::None | Paint::Solid { .. });
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let rigid = (a * a + b * b - 1.0).abs() < 1e-9 && (c * c + d * d - 1.0).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9;
|
||||
let stroked = t.runs.iter().any(|r| r.style.has_stroke());
|
||||
if !n.visible
|
||||
|| (stroked && !rigid)
|
||||
|| n.projection().is_some()
|
||||
|| !n.has_default_transparency()
|
||||
|| !n.appearance.effects.is_empty()
|
||||
|| n.appearance.items.iter().any(|i| paints(i) || !item_effects(i).is_empty())
|
||||
|| t.runs.iter().any(|r| r.inline.is_some() || r.style.underline || r.style.strikethrough || !flat(&r.style.fill) || !flat(&r.style.stroke))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (_, all) = glyph_outlines(t);
|
||||
let outline = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
let ink = outline.bounds()?;
|
||||
let offsets = transform_offsets(moves, &outline)?;
|
||||
if offsets.len() > MAX_TYPE_COPIES {
|
||||
return None;
|
||||
}
|
||||
let size = serde_json::to_vec(n).ok()?.len();
|
||||
if size.saturating_mul(offsets.len()) > MAX_TYPE_COPY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let strokes = t.runs.iter().map(|r| r.style.appearance().outset()).fold(0.0, f64::max);
|
||||
if !apart(&offsets, ink.inflate(strokes, strokes)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
offsets
|
||||
.into_iter()
|
||||
.map(|o| {
|
||||
let mut c = n.clone();
|
||||
c.transform(Affine::translate(o), false);
|
||||
c
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Do the copies of box `b` moved by each of `offsets` stay clear of each other? Copies may touch.
|
||||
/// False as well when an offset is 10^15 or more times the box's width or height.
|
||||
fn apart(offsets: &[Vec2], b: Rect) -> bool {
|
||||
let (w, h) = (b.width(), b.height());
|
||||
if offsets.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
// Copies closer than `w` across and `h` down lie in the same or a neighboring cell.
|
||||
let cell = |o: Vec2| {
|
||||
let (x, y) = ((o.x / w).floor(), (o.y / h).floor());
|
||||
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
|
||||
};
|
||||
let mut cells: HashMap<(i64, i64), Vec2> = HashMap::new();
|
||||
for &o in offsets {
|
||||
let Some((x, y)) = cell(o) else { return false };
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if let Some(p) = cells.get(&(x + dx, y + dy))
|
||||
&& (p.x - o.x).abs() < w
|
||||
&& (p.y - o.y).abs() < h
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.insert((x, y), o);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Type `n` whose own geometry effects only move it ([`transform_moves`]) as a group of the moved
|
||||
/// type ([`moved_type`]). The group keeps `n`'s id, name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects, and the type in it has none of them. `None` otherwise.
|
||||
fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
if !matches!(n.kind, NodeKind::Text(_)) {
|
||||
return None;
|
||||
}
|
||||
let moves = transform_moves(&geometry_effects(n))?;
|
||||
let mut plain = n.clone();
|
||||
plain.visible = true;
|
||||
plain.opacity = 1.0;
|
||||
plain.blend = Default::default();
|
||||
plain.isolate = false;
|
||||
plain.knockout_shape = false;
|
||||
plain.extras_mut().mask = None;
|
||||
plain.appearance.effects.clear();
|
||||
let copies = moved_type(&plain, &moves)?;
|
||||
let mut g = Node::group(n.id, copies.into_iter().map(Arc::new).collect());
|
||||
g.name = n.name.clone();
|
||||
g.visible = n.visible;
|
||||
g.opacity = n.opacity;
|
||||
g.blend = n.blend;
|
||||
g.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
g.knockout = Knockout::Off;
|
||||
g.knockout_shape = n.knockout_shape;
|
||||
g.extras_mut().mask = n.extras().mask.clone();
|
||||
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// `n`, a group or layer without Pathfinder effects or fills and strokes that paint, whose geometry
|
||||
/// effects only move its members ([`transform_moves`]), as plain art in which the type it holds
|
||||
/// stays type: each type object moved, or replaced by a group of its copies ([`moved_type`]).
|
||||
/// Type in a clipping path, other type that [`moved_type`] doesn't copy, and type inside live
|
||||
/// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is
|
||||
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
|
||||
/// them. `None` when no type stays type.
|
||||
fn container_keeping_type(n: &Node) -> Option<Node> {
|
||||
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
let clipping = clipping || (clips && i == 0);
|
||||
if !matches!(c.kind, NodeKind::Text(_)) {
|
||||
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
|
||||
outline_nested_text(Arc::make_mut(c), clipping);
|
||||
} else if c.children().is_some() {
|
||||
keep(Arc::make_mut(c), moves, clipping, kept);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Type that isn't copied is outlined as `outline_nested_text` outlines it.
|
||||
let copies = if clipping { None } else { moved_type(c, moves) };
|
||||
let art = match copies {
|
||||
Some(mut copies) => {
|
||||
*kept = true;
|
||||
match copies.pop() {
|
||||
Some(only) if copies.is_empty() => Some(only),
|
||||
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
|
||||
}
|
||||
}
|
||||
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
None => as_art(c, None),
|
||||
};
|
||||
if let Some(o) = art {
|
||||
*c = Arc::new(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || has_pathfinder(n) || has_own_paint(n) {
|
||||
return None;
|
||||
}
|
||||
let fx = geometry_effects(n);
|
||||
let moves = transform_moves(&fx)?;
|
||||
let mut text = false;
|
||||
n.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
|
||||
if !text {
|
||||
return None;
|
||||
}
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
let mut kept = false;
|
||||
keep(&mut m, &moves, false, &mut kept);
|
||||
if !kept {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
|
||||
/// none of `n`'s transparency.
|
||||
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
|
||||
@@ -359,8 +567,9 @@ pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Effect, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
@@ -408,4 +617,318 @@ mod tests {
|
||||
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
|
||||
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
|
||||
}
|
||||
|
||||
/// Black 24 pt "Type" with its baseline at (20, 60).
|
||||
fn type_node(id: u64) -> Node {
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))))
|
||||
}
|
||||
|
||||
/// The origins of the type objects in `d`, in paint order.
|
||||
fn type_origins(d: &Document) -> Vec<(f64, f64)> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let c = t.xf.as_coeffs();
|
||||
out.push((c[4], c[5]));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A group of the type and a 10 pt square with `effects`.
|
||||
fn type_group(effects: Vec<Effect>) -> Node {
|
||||
let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]);
|
||||
g.appearance.effects = effects;
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
|
||||
for (params, origins, square) in [
|
||||
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
|
||||
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
|
||||
(json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)),
|
||||
] {
|
||||
let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]);
|
||||
let d = bake_document(&doc_with(g.clone())).unwrap();
|
||||
assert_eq!(type_origins(&d), origins, "{params}");
|
||||
let baked = d.node(NodeId(52)).unwrap();
|
||||
assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
// The square moves with the type, as one path holding its copies.
|
||||
let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap();
|
||||
assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}");
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
|
||||
// The renderer and Expand Appearance still draw it as outlines.
|
||||
let mut text = 0;
|
||||
evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
|
||||
assert_eq!(text, 0, "{params}");
|
||||
}
|
||||
// Stacked effects that only move: every copy of a copy.
|
||||
let d = bake_document(&doc_with(type_group(vec![
|
||||
fx("distort.transform", json!({"moveH": 100, "copies": 1})),
|
||||
fx("distort.transform", json!({"moveV": 100, "copies": 1})),
|
||||
])))
|
||||
.unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut hidden = type_node(55);
|
||||
hidden.visible = false;
|
||||
g.children_mut().unwrap().push(Arc::new(hidden));
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
assert!(!d.node(NodeId(55)).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut shadowed = type_node(55);
|
||||
shadowed.opacity = 0.5;
|
||||
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
// Type that clips a group is outlined as the plain outlines it clips by.
|
||||
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true });
|
||||
g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]);
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
let o = d.node(NodeId(55)).unwrap();
|
||||
assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}");
|
||||
assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
// The outlines are copied with the type.
|
||||
let b = o.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}");
|
||||
let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap();
|
||||
assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
|
||||
let mut moved = type_node(60);
|
||||
moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300})));
|
||||
let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]);
|
||||
path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))));
|
||||
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true });
|
||||
g.children_mut().unwrap().push(Arc::new(clip));
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
|
||||
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
|
||||
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_its_own_transform_effect_copies_stays_type() {
|
||||
let mut n = type_node(50);
|
||||
n.opacity = 0.5;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]);
|
||||
// One group keeps the object's transparency and its other effects; the copies are plain.
|
||||
let g = d.node(NodeId(50)).unwrap();
|
||||
assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5);
|
||||
assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
for c in g.children().unwrap() {
|
||||
assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}");
|
||||
}
|
||||
// Hidden type stays hidden.
|
||||
let mut n = type_node(50);
|
||||
n.visible = false;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))];
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert_eq!(type_origins(&d).len(), 2);
|
||||
assert!(!d.node(NodeId(50)).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_under_other_geometry_effects_is_still_outlined() {
|
||||
for effects in [
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "random": true}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))],
|
||||
// Copies that would reach past the effect's limit, which stops them early.
|
||||
vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))],
|
||||
// Copies that overlap, or land on each other.
|
||||
vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))],
|
||||
] {
|
||||
let mut own = type_node(50);
|
||||
own.appearance.effects = effects.clone();
|
||||
for n in [type_group(effects.clone()), own] {
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert!(type_origins(&d).is_empty(), "{effects:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
|
||||
// A 20 pt stroke on the characters reaches the copy below.
|
||||
let mut stroked = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut stroked.kind {
|
||||
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
|
||||
t.runs[0].style.stroke_width = 20.0;
|
||||
}
|
||||
let mut g = type_group(moves);
|
||||
g.children_mut().unwrap()[0] = Arc::new(stroked);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
let mut translucent = type_node(50);
|
||||
translucent.opacity = 0.5;
|
||||
let mut underlined = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut underlined.kind {
|
||||
t.runs[0].style.underline = true;
|
||||
}
|
||||
let mut shadowed = type_node(50);
|
||||
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
let mut gradient = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut gradient.kind {
|
||||
t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
}
|
||||
let mut reshaped = type_node(50);
|
||||
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
fill.effects.push(fx("path.offsetPath", json!({"offset": 2})));
|
||||
reshaped.appearance.items.push(AppearanceItem::Fill(fill));
|
||||
// A stroke of the type object's own (beyond its characters').
|
||||
let mut stroked = type_node(50);
|
||||
stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
|
||||
// Character strokes scale with type scaled by its transform, where the outlines keep theirs.
|
||||
let mut scaled = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut scaled.kind {
|
||||
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
|
||||
t.runs[0].style.stroke_width = 1.0;
|
||||
t.xf *= Affine::scale(2.0);
|
||||
}
|
||||
let mut turned = scaled.clone();
|
||||
if let NodeKind::Text(t) = &mut turned.kind {
|
||||
t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5);
|
||||
}
|
||||
let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))];
|
||||
for (t, kept) in [(turned, 2), (scaled, 0)] {
|
||||
let mut g = type_group(far.clone());
|
||||
g.children_mut().unwrap()[0] = Arc::new(t);
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept);
|
||||
}
|
||||
for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] {
|
||||
let mut g = type_group(moves.clone());
|
||||
g.children_mut().unwrap()[0] = Arc::new(t);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))];
|
||||
let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)));
|
||||
let mut g = type_group(moves.clone());
|
||||
g.appearance.items.push(fill.clone());
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
// A group with a fill of its own inside the moved group.
|
||||
let mut inner = type_group(vec![]);
|
||||
inner.id = NodeId(53);
|
||||
inner.appearance.items.push(fill);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects = moves;
|
||||
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() {
|
||||
// The inner copy lands back on the original.
|
||||
let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1})));
|
||||
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_hold_too_much_is_still_outlined() {
|
||||
let spaced = |spaces: usize| {
|
||||
let mut n = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.runs[0].text = format!("a{}", " ".repeat(spaces));
|
||||
}
|
||||
Arc::new(n)
|
||||
};
|
||||
// 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000
|
||||
// bytes together, they are outlined.
|
||||
let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))];
|
||||
for (spaces, kept) in [(1000, 1001), (20_000, 0)] {
|
||||
let mut g = type_group(copies.clone());
|
||||
g.children_mut().unwrap()[0] = spaced(spaces);
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces");
|
||||
}
|
||||
// 1001 × 21 copies, past 10,000.
|
||||
let mut g = type_group(vec![
|
||||
fx("distort.transform", json!({"moveH": 100, "copies": 1000})),
|
||||
fx("distort.transform", json!({"moveV": 100, "copies": 20})),
|
||||
]);
|
||||
g.children_mut().unwrap()[0] = spaced(0);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_are_apart_when_their_boxes_only_touch() {
|
||||
let b = Rect::new(0.0, 0.0, 10.0, 5.0);
|
||||
assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b));
|
||||
assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b));
|
||||
assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b));
|
||||
assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b));
|
||||
// Offsets too far for the cells' range count as overlapping.
|
||||
assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
|
||||
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1})));
|
||||
let d = bake_document(&doc_with(outer)).unwrap();
|
||||
assert!(type_origins(&d).is_empty());
|
||||
// The glyphs take part in Add: the result reaches past the 10 pt square.
|
||||
let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap();
|
||||
assert!(b.width() > 30.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_copied_only_when_the_effect_makes_every_copy() {
|
||||
// A 10 pt line with `n` anchors.
|
||||
let line = |n: usize| {
|
||||
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect();
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)])
|
||||
};
|
||||
let right = json!({"moveH": 10, "copies": 1000});
|
||||
let down = json!({"moveV": 10, "copies": 1});
|
||||
// 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors.
|
||||
assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001));
|
||||
assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none());
|
||||
// A second effect copies every copy.
|
||||
assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002));
|
||||
assert!(transform_offsets(&[right, down], &line(1000)).is_none());
|
||||
// Copies stop before one reaches 1,000,000,000 pt from the origin.
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100));
|
||||
assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none());
|
||||
// An effect that also scales.
|
||||
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
|
||||
}
|
||||
}
|
||||
@@ -103,11 +103,59 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share.
|
||||
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
|
||||
let (m, copies) = transform_map(Rect::ZERO, p, 0);
|
||||
let [a, b, c, d, x, y] = m.as_coeffs();
|
||||
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
|
||||
}
|
||||
|
||||
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
|
||||
/// original's place first, when each of them only moves it ([`transform_move`]): each effect moves
|
||||
/// every place it is given, or with copies keeps them and adds each copy's. `None` when one of
|
||||
/// them does more than move, or when [`transform`] stops its copies of `path` early.
|
||||
pub(crate) fn transform_offsets(ps: &[Value], path: &PathData) -> Option<Vec<Vec2>> {
|
||||
let mut offsets = vec![Vec2::ZERO];
|
||||
let mut all = path.clone();
|
||||
for p in ps {
|
||||
let (v, copies) = transform_move(p)?;
|
||||
let next = transform(&all, Rect::ZERO, p, 0);
|
||||
if next.anchor_count() != all.anchor_count().saturating_mul(copies + 1) {
|
||||
return None;
|
||||
}
|
||||
all = next;
|
||||
if copies == 0 {
|
||||
offsets.iter_mut().for_each(|o| *o += v);
|
||||
continue;
|
||||
}
|
||||
let from = offsets.clone();
|
||||
offsets.extend((1..=copies).flat_map(|k| from.iter().map(move |o| *o + v * k as f64)));
|
||||
}
|
||||
Some(offsets)
|
||||
}
|
||||
|
||||
/// Largest scale (%) and move (pt) the Transform effect takes.
|
||||
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;
|
||||
/// Most image and symbol instance nodes a Transform effect's copies make of an art, the originals
|
||||
/// included: 1000 copies of a 2000-image art. Stacked Transform effects multiply their copies
|
||||
/// (three of 1000 copies make a thousand million of one image), so the limit is on the result,
|
||||
/// not on each effect's count.
|
||||
pub(crate) const MAX_COPY_OBJECTS: usize = 2_000_000;
|
||||
|
||||
/// Does `r` stay within [`MAX_REACH`] of the origin? A Transform effect's copies stop before one
|
||||
/// doesn't.
|
||||
pub(crate) fn within_reach(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -115,6 +163,41 @@ 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 (m, copies) = transform_map(b, p, seed);
|
||||
if copies == 0 {
|
||||
return path.transformed(m);
|
||||
}
|
||||
// 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_reach) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
|
||||
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
|
||||
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
|
||||
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
|
||||
/// [`MAX_COPY_OBJECTS`]).
|
||||
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
|
||||
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 };
|
||||
@@ -128,20 +211,7 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
|
||||
* Affine::translate(-o);
|
||||
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
|
||||
if copies == 0 {
|
||||
return path.transformed(m);
|
||||
}
|
||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
}
|
||||
out
|
||||
(m, copies)
|
||||
}
|
||||
|
||||
/// Tweak: random displacement of anchors and/or control points.
|
||||
|
||||
+139
-6
@@ -46,7 +46,9 @@ pub fn has_pathfinder(n: &Node) -> bool {
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
|
||||
}
|
||||
|
||||
/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible.
|
||||
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
|
||||
/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation
|
||||
/// with the object's opacity and blend mode.
|
||||
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
|
||||
|
||||
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
@@ -61,8 +63,14 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = hook.and_then(|h| h(n)) {
|
||||
out.push(p);
|
||||
// Every path of the outline art, at any depth, takes the object's opacity and blend
|
||||
// mode.
|
||||
if let Some(art) = hook.and_then(|h| h(n)) {
|
||||
let first = out.len();
|
||||
leaves(&art, None, out);
|
||||
for l in out.iter_mut().skip(first) {
|
||||
(l.opacity, l.blend) = (n.opacity, n.blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,9 +289,9 @@ pub fn evaluate_container(n: &Node) -> Option<Node> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Effect, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
|
||||
Arc::new(Node::path(
|
||||
@@ -319,6 +327,71 @@ mod tests {
|
||||
assert_eq!(div.len(), 3);
|
||||
}
|
||||
|
||||
/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either
|
||||
/// side of x = 100.
|
||||
fn type_h() -> Node {
|
||||
let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() };
|
||||
Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style))))
|
||||
}
|
||||
|
||||
/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it.
|
||||
fn square_and(front: Option<Node>, effect: &str) -> Node {
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red);
|
||||
let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect());
|
||||
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
|
||||
g
|
||||
}
|
||||
|
||||
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
|
||||
fn results(g: &Node) -> Vec<Arc<Node>> {
|
||||
pathfinder_children(g, crate::text_outliner()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_changes_the_result_of_every_pathfinder_effect() {
|
||||
let glyphs = crate::outline_text(&type_h()).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
|
||||
// `measure` gives the number of results and their total area.
|
||||
let measure = |front: Option<Node>, effect: &str| {
|
||||
let r = results(&square_and(front, effect));
|
||||
(r.len(), r.iter().map(|n| area(n)).sum::<f64>())
|
||||
};
|
||||
let unchanged: Vec<_> = PATHFINDER_EFFECTS
|
||||
.iter()
|
||||
.map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id)))
|
||||
.filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0)
|
||||
.collect();
|
||||
assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}");
|
||||
// The shape modes take the glyph's whole area: the part inside the square and the part outside.
|
||||
let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum();
|
||||
let with_type = |id: &str| measure(Some(type_h()), id).1;
|
||||
assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}");
|
||||
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
|
||||
// Outline art nested one group deeper takes part as well.
|
||||
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
|
||||
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
|
||||
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
|
||||
let mut t = type_h();
|
||||
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
|
||||
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
|
||||
let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal);
|
||||
let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply);
|
||||
let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect();
|
||||
assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}");
|
||||
// In the shape modes a group with the type in front is one shape, styled like the type.
|
||||
let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
|
||||
let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green);
|
||||
let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]);
|
||||
let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect();
|
||||
assert_eq!(added, [glyph]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_random_transform_on_a_group_moves_its_members_together() {
|
||||
let mut g = group_with("distort.transform");
|
||||
@@ -330,6 +403,40 @@ mod tests {
|
||||
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
|
||||
/// in a group with a square and Offset Path 0, which leaves every path as it is.
|
||||
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
|
||||
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
|
||||
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
|
||||
style(&mut t);
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
|
||||
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
(*art.children().unwrap()[1]).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
|
||||
use vectorcraft_color::BlendMode;
|
||||
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
|
||||
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
|
||||
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
|
||||
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
|
||||
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
|
||||
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
|
||||
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
|
||||
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
|
||||
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
|
||||
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
|
||||
assert!(!type_in_group(|t| t.visible = false).visible);
|
||||
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
|
||||
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
|
||||
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
|
||||
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
|
||||
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_effect_or_plain_group_is_untouched() {
|
||||
let mut g = group_with("pathfinder.add");
|
||||
@@ -337,4 +444,30 @@ mod tests {
|
||||
assert!(pathfinder_children(&g, None).is_none());
|
||||
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
// The type's characters fit the gradient to its layout; the square has it placed across itself.
|
||||
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
|
||||
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
|
||||
let start = |n: &Node| match n.appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.unwrap().start.x,
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
};
|
||||
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
|
||||
let mut g = Node::group(NodeId(10), vec![square, t]);
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let members = art.children().unwrap();
|
||||
let after = start(&members[1].children().unwrap()[0]);
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
|
||||
}
|
||||
}
|
||||
+96
-22
@@ -134,7 +134,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"threeD.inflate" => always(&["depth"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
@@ -200,29 +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), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; 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,"cap":true,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{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), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{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), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{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",
|
||||
@@ -249,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})),
|
||||
@@ -301,15 +301,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.scribble",
|
||||
"Scribble…",
|
||||
STYLIZE,
|
||||
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
|
||||
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
|
||||
"{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
|
||||
json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}),
|
||||
),
|
||||
r(
|
||||
"stylize.dropShadow",
|
||||
"Drop Shadow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.innerGlow",
|
||||
@@ -345,8 +345,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -415,7 +415,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.glass",
|
||||
"Glass…",
|
||||
DISTORT,
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
|
||||
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
|
||||
),
|
||||
r(
|
||||
@@ -642,21 +642,83 @@ 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), in their current form (see
|
||||
/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and
|
||||
/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`.
|
||||
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`). Scribble took one `variation` before its
|
||||
/// separate `overlapVariation` and `spacingVariation`. 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.
|
||||
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
|
||||
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);
|
||||
upgrade_scribble(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));
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
|
||||
if id != "stylize.scribble" {
|
||||
return;
|
||||
}
|
||||
if let Some(variation) = m.remove("variation") {
|
||||
for key in ["overlapVariation", "spacingVariation"] {
|
||||
m.entry(key).or_insert_with(|| variation.clone());
|
||||
}
|
||||
}
|
||||
if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) {
|
||||
let wrapped = angle.rem_euclid(360.0);
|
||||
m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped }));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -706,6 +768,18 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && is_geometry(&e.id))
|
||||
}
|
||||
|
||||
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
|
||||
/// what the plug-in installed under its id draws ([`plugin_revision`]).
|
||||
pub fn has_plugin_effect(n: &Node) -> bool {
|
||||
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
|
||||
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
|
||||
}
|
||||
|
||||
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
|
||||
pub fn plugin_revision() -> u64 {
|
||||
vectorcraft_plugins::registry::revision()
|
||||
}
|
||||
|
||||
/// Context the geometry effects may need from the object's appearance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct GeomContext<'a> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Blur › Radial Blur and Smart Blur.
|
||||
//! Blur › Radial Blur and Smart Blur (Normal, Edge Only and Overlay Edge).
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::{PixelSpace, Px16, box_blur, to8, to16};
|
||||
use super::{PixelSpace, Px16, box_blur, pick, to8, to16};
|
||||
|
||||
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
|
||||
pub(super) fn zoom_extent(amount: f64) -> f64 {
|
||||
@@ -69,6 +69,39 @@ pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
|
||||
const MAX_SMART: f64 = 4096.0;
|
||||
|
||||
/// Smart Blur's modes, in its Mode menu's order: (label, parameter value).
|
||||
pub const SMART_BLUR_MODES: [(&str, &str); 3] = [("Normal", "normal"), ("Edge Only", "edgeOnly"), ("Overlay Edge", "overlayEdge")];
|
||||
|
||||
/// What Smart Blur draws ([`SMART_BLUR_MODES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SmartBlurMode {
|
||||
/// The object blurred.
|
||||
Normal,
|
||||
/// The edges in white on black.
|
||||
EdgeOnly,
|
||||
/// The edges in white over the object blurred.
|
||||
OverlayEdge,
|
||||
}
|
||||
|
||||
impl SmartBlurMode {
|
||||
const ALL: [SmartBlurMode; 3] = [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge];
|
||||
|
||||
/// The mode of `value` (a [`SMART_BLUR_MODES`] value, any case); Normal otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&SMART_BLUR_MODES, &Self::ALL, value, SmartBlurMode::Normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart Blur's `threshold` (8-bit levels) in the levels of [`Px16`].
|
||||
fn levels(threshold: f64) -> i32 {
|
||||
(threshold.clamp(0.0, 255.0) * 257.0) as i32
|
||||
}
|
||||
|
||||
/// Do all of `a`'s channels lie within `t` levels of `b`'s?
|
||||
fn close(a: &Px16, b: &Px16, t: i32) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
|
||||
}
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
@@ -102,7 +135,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
|
||||
let t = levels(threshold);
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let mut acc = c.map(u64::from);
|
||||
@@ -115,8 +148,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
let i = sy as usize * w + sx as usize;
|
||||
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
|
||||
// The neighbour itself and its surroundings both close enough: edges stay sharp.
|
||||
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
|
||||
if close(own) && close(s) {
|
||||
if close(own, c, t) && close(s, c, t) {
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
@@ -127,3 +159,45 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
|
||||
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
|
||||
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
|
||||
/// channel more than `threshold` levels from its own and its channels add up to at least the
|
||||
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
|
||||
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
|
||||
/// pixel's coverage.
|
||||
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
|
||||
let src = to16(px);
|
||||
let t = levels(threshold);
|
||||
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
|
||||
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
|
||||
if x >= w || y >= h {
|
||||
return [0; 4];
|
||||
}
|
||||
src.get(y * w + x).copied().unwrap_or([0; 4])
|
||||
};
|
||||
let edge: Vec<bool> = src
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = at(x, y, *dx, *dy);
|
||||
!close(&n, c, t) && sum(c) >= sum(&n)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if overlay {
|
||||
smart(px, w, h, radius, threshold, samples);
|
||||
}
|
||||
for ((p, c), on_edge) in px.iter_mut().zip(&src).zip(&edge) {
|
||||
let a = to8(c[3]);
|
||||
if *on_edge {
|
||||
*p = [a; 4];
|
||||
} else if !overlay {
|
||||
*p = [0, 0, 0, a];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
//! white grain. Every surface lies in document space around the object's centre, as the Texture
|
||||
//! filters' do.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::loaded::{LOADED, LoadedTexture, texture};
|
||||
use super::pixelate::{Place, value_noise};
|
||||
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
@@ -20,13 +23,19 @@ const SALT_RIPPLE: u64 = 136;
|
||||
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
|
||||
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
|
||||
|
||||
/// Glass's Texture menu once a texture is loaded: [`GLASS_TEXTURES`], then the loaded one.
|
||||
pub const GLASS_TEXTURES_LOADED: [(&str, &str); 5] =
|
||||
[("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens"), ("Loaded Texture", LOADED)];
|
||||
|
||||
/// A Glass surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GlassTexture {
|
||||
Blocks,
|
||||
Canvas,
|
||||
Frosted,
|
||||
TinyLens,
|
||||
/// An image the user loaded ([`LoadedTexture`]).
|
||||
Loaded(Arc<LoadedTexture>),
|
||||
}
|
||||
|
||||
impl GlassTexture {
|
||||
@@ -37,13 +46,23 @@ impl GlassTexture {
|
||||
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
|
||||
}
|
||||
|
||||
/// The surface of `value` with `stored` the effect's `textureImage`: the loaded texture
|
||||
/// for [`LOADED`] (any case) when `stored` holds one, else as [`GlassTexture::parse`].
|
||||
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
|
||||
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
|
||||
Some(t) => GlassTexture::Loaded(t),
|
||||
None => Self::parse(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
fn height(&self, p: Point) -> f64 {
|
||||
match self {
|
||||
GlassTexture::Blocks => blocks(p),
|
||||
GlassTexture::Canvas => Texture::Canvas.height(p),
|
||||
GlassTexture::Frosted => frosted(p),
|
||||
GlassTexture::TinyLens => tiny_lens(p),
|
||||
GlassTexture::Loaded(t) => t.height(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +122,7 @@ pub(super) fn glass(
|
||||
space: &PixelSpace,
|
||||
distortion: f64,
|
||||
smoothness: f64,
|
||||
texture: GlassTexture,
|
||||
texture: &GlassTexture,
|
||||
scaling: f64,
|
||||
invert: bool,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Load Texture (Effect › Distort › Glass): a surface made from an image the user loads.
|
||||
//!
|
||||
//! The effect keeps the image's gray levels in its parameters ([`TEXTURE_IMAGE`]: a grayscale PNG,
|
||||
//! base64), so the document saves the texture with the effect, and copies of the effect carry it.
|
||||
//! Each pixel's gray is the surface's height there: white stands [`RELIEF`] above black. The image
|
||||
//! lies one pixel per point at 100 %, centered on the object's center and repeated in both
|
||||
//! directions.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::texture::luma;
|
||||
|
||||
/// The effect parameter that holds a loaded texture: its gray levels as a grayscale PNG, base64.
|
||||
pub const TEXTURE_IMAGE: &str = "textureImage";
|
||||
|
||||
/// The Texture menu's value for the loaded texture.
|
||||
pub const LOADED: &str = "loaded";
|
||||
|
||||
/// How far (points) a loaded texture's white stands above its black: a black pixel beside a white
|
||||
/// one gives a slope of about 1, the most the built-in surfaces have.
|
||||
const RELIEF: f64 = 1.0;
|
||||
|
||||
/// How many decoded textures [`texture`] keeps.
|
||||
const KEEP: usize = 4;
|
||||
|
||||
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LoadedTexture {
|
||||
width: u32,
|
||||
height: u32,
|
||||
gray: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LoadedTexture {
|
||||
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
|
||||
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
|
||||
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
|
||||
pub fn from_rgba(width: u32, height: u32, rgba: &[u8]) -> Option<Self> {
|
||||
let n = (width as usize).checked_mul(height as usize).filter(|n| *n > 0)?;
|
||||
let (px, rest) = rgba.as_chunks::<4>();
|
||||
if px.len() != n || !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let gray = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let l = luma([p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0));
|
||||
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
})
|
||||
.collect();
|
||||
Some(Self { width, height, gray })
|
||||
}
|
||||
|
||||
/// The size in pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Its [`TEXTURE_IMAGE`] value: the gray levels as a grayscale PNG, base64; `None` when the
|
||||
/// PNG can't be written.
|
||||
pub fn to_stored(&self) -> Option<String> {
|
||||
let img = image::GrayImage::from_raw(self.width, self.height, self.gray.clone())?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(base64_encode(&png))
|
||||
}
|
||||
|
||||
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
|
||||
/// `None` when `png` isn't a PNG or holds no pixels.
|
||||
fn from_png(png: &[u8]) -> Option<Self> {
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_luma8();
|
||||
let (width, height) = img.dimensions();
|
||||
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
|
||||
}
|
||||
|
||||
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
|
||||
/// levels around it interpolated, 0 for black and [`RELIEF`] for white.
|
||||
pub(super) fn height(&self, p: Point) -> f64 {
|
||||
let (w, h) = (f64::from(self.width), f64::from(self.height));
|
||||
// Pixel centers lie at whole coordinates, and the image's center lies at the origin.
|
||||
let (x, y) = (p.x + 0.5 * w - 0.5, p.y + 0.5 * h - 0.5);
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = (x - x0, y - y0);
|
||||
let at = |i: f64, j: f64| {
|
||||
let (i, j) = (i.rem_euclid(w) as usize, j.rem_euclid(h) as usize);
|
||||
self.gray.get(j * self.width as usize + i).map_or(0.5, |g| f64::from(*g) / 255.0)
|
||||
};
|
||||
let top = at(x0, y0) + (at(x0 + 1.0, y0) - at(x0, y0)) * fx;
|
||||
let bottom = at(x0, y0 + 1.0) + (at(x0 + 1.0, y0 + 1.0) - at(x0, y0 + 1.0)) * fx;
|
||||
RELIEF * (top + (bottom - top) * fy)
|
||||
}
|
||||
}
|
||||
|
||||
/// The textures [`texture`] decoded last, with their stored form, the newest last.
|
||||
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
|
||||
/// [`KEEP`] textures decoded are kept for the next call.
|
||||
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
|
||||
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
|
||||
let entry = decoded.remove(i);
|
||||
let t = entry.1.clone();
|
||||
decoded.push(entry);
|
||||
return Some(t);
|
||||
}
|
||||
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
|
||||
if decoded.len() >= KEEP {
|
||||
decoded.remove(0);
|
||||
}
|
||||
decoded.push((stored.to_string(), t.clone()));
|
||||
Some(t)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding), written as `vectorcraft_format::base64_encode`
|
||||
/// writes it.
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
out.push(B64[(n >> 18) as usize & 63] as char);
|
||||
out.push(B64[(n >> 12) as usize & 63] as char);
|
||||
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding; whitespace skipped), read as
|
||||
/// `vectorcraft_format::base64_decode` reads it; `None` when it isn't base64.
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
|
||||
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if !clean.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for c in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
let mut pad = 0;
|
||||
for &b in c {
|
||||
n <<= 6;
|
||||
if b == b'=' {
|
||||
pad += 1;
|
||||
} else {
|
||||
n |= val(b)?;
|
||||
}
|
||||
}
|
||||
out.push((n >> 16) as u8);
|
||||
if pad < 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if pad < 1 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod blur;
|
||||
mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod loaded;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod texture;
|
||||
@@ -27,8 +28,10 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
|
||||
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GlassTexture};
|
||||
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
@@ -66,7 +69,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, 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.
|
||||
@@ -77,7 +80,10 @@ pub enum PixelFx {
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
|
||||
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
|
||||
/// result.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
@@ -107,9 +113,9 @@ pub enum PixelFx {
|
||||
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
|
||||
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
|
||||
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
|
||||
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
|
||||
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
|
||||
/// `invert`.
|
||||
/// Distort › Glass: the object seen through a `texture` surface (a built-in one, or an image
|
||||
/// the user loaded) scaled by `scaling` (0.5..2), bent by `distortion` (0..20), smoothed by
|
||||
/// `smoothness` (1..15), its heights turned over when `invert`.
|
||||
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
|
||||
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
|
||||
/// `magnitude` (0..20) says.
|
||||
@@ -211,6 +217,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
mode: SmartBlurMode::parse(text(p, "blurMode", "normal")),
|
||||
},
|
||||
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
@@ -258,7 +265,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
"distort.glass" => PixelFx::Glass {
|
||||
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
texture: GlassTexture::parse(text(p, "texture", "frosted")),
|
||||
texture: GlassTexture::parse_with(text(p, "texture", "frosted"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
@@ -412,7 +419,12 @@ impl PixelFx {
|
||||
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::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
|
||||
blur::smart(px, w, h, to_px(radius), threshold, samples)
|
||||
}
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode } => {
|
||||
blur::smart_edges(px, w, h, to_px(radius), threshold, samples, mode == SmartBlurMode::OverlayEdge)
|
||||
}
|
||||
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
|
||||
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
|
||||
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
|
||||
@@ -422,7 +434,7 @@ impl PixelFx {
|
||||
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
|
||||
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
|
||||
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
|
||||
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
|
||||
PixelFx::Glass { distortion, smoothness, ref texture, scaling, invert } => {
|
||||
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
@@ -448,8 +460,8 @@ impl PixelFx {
|
||||
|
||||
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
|
||||
/// otherwise.
|
||||
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
|
||||
fn pick<T: Clone>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| t.clone())
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
|
||||
@@ -36,7 +36,7 @@ fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => *p,
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -465,11 +465,22 @@ fn params_take_defaults_and_stay_in_range() {
|
||||
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
|
||||
);
|
||||
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::Normal });
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5, mode: SmartBlurMode::EdgeOnly }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::OverlayEdge }
|
||||
);
|
||||
assert_eq!(fx("blur.smart", json!({"blurMode": "overlay"})), fx("blur.smart", json!({})));
|
||||
// Every Mode menu value is documented in the catalogue and parses to a mode of its own.
|
||||
let doc = crate::effect_info("blur.smart").unwrap().params;
|
||||
for (i, (_, v)) in SMART_BLUR_MODES.iter().enumerate() {
|
||||
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
|
||||
assert_eq!(SmartBlurMode::parse(v), [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge][i], "{v}");
|
||||
}
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
|
||||
assert_eq!(
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
@@ -616,6 +627,105 @@ fn smart_blur_smooths_noise_and_keeps_edges() {
|
||||
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
|
||||
}
|
||||
|
||||
/// FNV-1a of `bytes`.
|
||||
fn digest(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_normal_mode_keeps_its_pixels() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 && y < 36 { 255 } else { 128 };
|
||||
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
|
||||
});
|
||||
let mut sums = Vec::new();
|
||||
for p in [json!({}), json!({"radius": 2, "threshold": 10, "quality": "high"}), json!({"radius": 12, "threshold": 40, "quality": "low"})] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", p.clone()).apply(&mut d, w, h, &space(w, h));
|
||||
let mut normal = src.clone();
|
||||
let mut p = p;
|
||||
p["blurMode"] = json!("normal");
|
||||
fx("blur.smart", p).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_eq!(d, normal);
|
||||
sums.push(digest(&d));
|
||||
}
|
||||
// The pixels Smart Blur gave before it had a mode.
|
||||
assert_eq!(sums, [11495339692959753241, 14281410954765351056, 5111933201350526354]);
|
||||
}
|
||||
|
||||
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
|
||||
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
|
||||
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
|
||||
/// beyond it) and column 6, the step's lighter side.
|
||||
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
|
||||
let (w, h) = (12, 8);
|
||||
let src = image(w, h, |x, y| match x {
|
||||
0..3 => grey(if (x + y) % 2 == 0 { 56 } else { 64 }),
|
||||
3..6 => grey(if (x + y) % 2 == 0 { 66 } else { 74 }),
|
||||
_ => grey(200),
|
||||
});
|
||||
(w, h, src, move |x, y| x == 0 || y == 0 || x == w - 1 || y == h - 1 || x == 6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
|
||||
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want);
|
||||
// The radius changes only the blur.
|
||||
let mut wide = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
|
||||
assert_eq!(wide, want);
|
||||
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
|
||||
let mut low = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..6 {
|
||||
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
|
||||
assert_eq!(at(&low, w, x, y)[0] == 255, lighter, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
// White and black keep each pixel's coverage, and transparency stays.
|
||||
let (w, h) = (8, 8);
|
||||
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
|
||||
let mut s = shape.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
|
||||
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
|
||||
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
|
||||
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
|
||||
(true, _) => [128; 4],
|
||||
(_, true) => [0, 0, 0, 128],
|
||||
_ => [0; 4],
|
||||
});
|
||||
assert_eq!(s, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_ne!(normal, src, "the checkerboard is blurred");
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
|
||||
assert_eq!(at(&d, w, x, y), want, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
// Parameters past their ranges, at any resolution, still finish.
|
||||
for mode in ["edgeOnly", "overlayEdge"] {
|
||||
for s in [PixelSpace { px: 1e-9, ..space(w, h) }, PixelSpace { px: 1e9, ..space(w, h) }] {
|
||||
let mut e = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": mode, "radius": 1e308, "threshold": 1e308, "quality": "high"})).apply(&mut e, w, h, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
@@ -1309,6 +1419,68 @@ fn glass_bends_the_image_by_its_texture() {
|
||||
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
|
||||
}
|
||||
|
||||
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
|
||||
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
|
||||
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_texture_is_its_gray_over_middle_gray() {
|
||||
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
|
||||
let loaded = texture(&t).unwrap();
|
||||
assert_eq!(loaded.size(), (3, 1));
|
||||
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
|
||||
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
|
||||
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
|
||||
// center, and x = 2 is the same pixel again.
|
||||
let h = |x: f64| loaded.height(Point::new(x, 0.0));
|
||||
assert_eq!(h(-1.0), 1.0);
|
||||
assert_eq!(h(0.0), 128.0 / 255.0);
|
||||
assert_eq!(h(1.0), 76.0 / 255.0);
|
||||
assert_eq!(h(2.0), 1.0);
|
||||
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
|
||||
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
|
||||
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_bends_the_image_by_a_loaded_texture() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = stripes(w, h);
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = src.clone();
|
||||
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
|
||||
d
|
||||
};
|
||||
// Rings around the texture's center, 9 × 7 pixels, repeated.
|
||||
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
|
||||
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
|
||||
let bent = run(loaded.clone());
|
||||
assert_ne!(bent, src);
|
||||
for (_, texture) in GLASS_TEXTURES {
|
||||
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
|
||||
}
|
||||
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
|
||||
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
|
||||
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
|
||||
let p = at(&bent, w, x, y);
|
||||
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
|
||||
}
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
|
||||
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
|
||||
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
|
||||
}
|
||||
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
|
||||
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
|
||||
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
|
||||
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
|
||||
assert_eq!(
|
||||
fx("distort.glass", loaded),
|
||||
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
|
||||
let (w, h) = (64, 32);
|
||||
|
||||
@@ -21,6 +21,9 @@ pub enum RasterFx {
|
||||
dy: f64,
|
||||
blur: f64,
|
||||
color: Color,
|
||||
/// Darkness (0..1): the shadow is the object's own colors with this share of black added
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
|
||||
blur,
|
||||
color: color_param(&p, Color::BLACK),
|
||||
darkness: text(&p, "shade", "color")
|
||||
.eq_ignore_ascii_case("darkness")
|
||||
.then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32),
|
||||
},
|
||||
"stylize.outerGlow" => RasterFx::OuterGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
|
||||
+223
-21
@@ -10,7 +10,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -30,9 +30,17 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
/// path per run, and all of them as one.
|
||||
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let (runs, all, _) = laid_out(t);
|
||||
(runs, all)
|
||||
}
|
||||
|
||||
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
|
||||
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let layout = vectorcraft_text::layout(db, t);
|
||||
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in &layout.glyphs {
|
||||
@@ -41,25 +49,49 @@ pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::Bez
|
||||
}
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
(runs, all)
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
if let Some(r) = runs.get_mut(run) {
|
||||
r.extend(bar.iter());
|
||||
}
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
(runs, all, layout.bounds)
|
||||
}
|
||||
|
||||
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
|
||||
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
|
||||
/// the characters under them and the others over them, as the renderer paints them.
|
||||
/// the characters under them and the others over them, as the renderer paints them. Each run's
|
||||
/// path is placed by the type's transform, which also scales its stroke weights and dashes and
|
||||
/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in
|
||||
/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds
|
||||
/// in the document. The group is marked as type's outlines
|
||||
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
|
||||
pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let (runs, all) = glyph_outlines(t);
|
||||
let (runs, all, bounds) = laid_out(t);
|
||||
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
|
||||
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
|
||||
let mut children: Vec<Arc<Node>> = runs
|
||||
.iter()
|
||||
.zip(&t.runs)
|
||||
.filter(|(bp, _)| !bp.elements().is_empty())
|
||||
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
|
||||
.map(|(bp, run)| {
|
||||
let mut ap = run.style.appearance();
|
||||
ap.pin_gradients(bounds);
|
||||
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
|
||||
piece.transform(t.xf, scaling);
|
||||
Arc::new(piece)
|
||||
})
|
||||
.collect();
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
|
||||
let tb = t.xf.transform_rect_bbox(bounds);
|
||||
let glyphs = |items: &[AppearanceItem]| {
|
||||
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
|
||||
ap.pin_gradients(tb);
|
||||
Arc::new(Node::path(n.id, path(&all), ap))
|
||||
};
|
||||
if !below.is_empty() {
|
||||
children.insert(0, glyphs(below));
|
||||
}
|
||||
@@ -67,7 +99,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
children.push(glyphs(above));
|
||||
}
|
||||
}
|
||||
Some(Node::group(n.id, children))
|
||||
let mut g = Node::group(n.id, children);
|
||||
g.extras_mut().type_outlines = true;
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// `n` as plain art that geometry effects can reshape, without its transparency and object
|
||||
@@ -86,7 +120,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ => return None,
|
||||
};
|
||||
outline_nested_text(&mut art);
|
||||
outline_nested_text(&mut art, false);
|
||||
art.opacity = 1.0;
|
||||
art.blend = Default::default();
|
||||
art.isolate = false;
|
||||
@@ -97,22 +131,33 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Replace type anywhere inside `n` by its outlines.
|
||||
fn outline_nested_text(n: &mut Node) {
|
||||
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
|
||||
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
|
||||
/// its plain outlines ([`outline_text`]) with its visibility, which is what
|
||||
/// [`crate::clip_outline`] clips by.
|
||||
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
if let Some(o) = outline_text(c) {
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
let clipping = clipping || (clips && i == 0);
|
||||
let art = match &c.kind {
|
||||
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
NodeKind::Text(_) => as_art(c, None),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(o) = art {
|
||||
*c = Arc::new(o);
|
||||
} else if c.children().is_some() {
|
||||
outline_nested_text(Arc::make_mut(c));
|
||||
outline_nested_text(Arc::make_mut(c), clipping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
|
||||
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
|
||||
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
|
||||
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
|
||||
/// perspective, or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
return None;
|
||||
@@ -154,11 +199,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
out.opacity = n.opacity;
|
||||
out.blend = n.blend;
|
||||
out.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.knockout_shape = n.knockout_shape;
|
||||
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)
|
||||
@@ -197,9 +244,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
}
|
||||
|
||||
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together).
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
|
||||
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
let mut outlines = vec![];
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
@@ -208,7 +261,156 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if e.id == "distort.transform" {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
/// order. A group inside one of them is part of it.
|
||||
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
if n.extras().type_outlines {
|
||||
out.push(n.geometric_bounds());
|
||||
} else if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
|
||||
for c in children {
|
||||
type_outline_bounds(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move and scale the placed gradients of each group of type's outlines under `n` from its bounds
|
||||
/// in `before` ([`type_outline_bounds`] before its paths changed) to its bounds now.
|
||||
fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
|
||||
if !n.extras().type_outlines {
|
||||
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
|
||||
for c in children.iter_mut() {
|
||||
rebase_type_outlines(Arc::make_mut(c), before);
|
||||
}
|
||||
}
|
||||
} else if let Some(Some(from)) = before.next()
|
||||
&& let Some(to) = n.geometric_bounds()
|
||||
&& from != to
|
||||
{
|
||||
for_each_leaf(n, &mut |leaf| {
|
||||
if leaf.appearance.has_placed_gradient() {
|
||||
follow_boxes(&mut leaf.appearance, from, to);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
|
||||
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
|
||||
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
|
||||
/// mapped with them.
|
||||
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
|
||||
for i in &mut ap.items {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => {
|
||||
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut f.paint {
|
||||
g.transform(box_map(from, to));
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let m = box_map(s.paint_bounds(from), s.paint_bounds(to));
|
||||
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut s.paint {
|
||||
g.transform(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The map that takes box `from` onto box `to` along each axis (along an axis `from` has no extent
|
||||
/// on, a move from its center to `to`'s).
|
||||
fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
|
||||
let axis = |a0: f64, a1: f64, b0: f64, b1: f64| {
|
||||
if (a1 - a0).abs() > 1e-12 {
|
||||
let s = (b1 - b0) / (a1 - a0);
|
||||
(s, b0 - a0 * s)
|
||||
} else {
|
||||
(1.0, (b0 + b1 - a0 - a1) / 2.0)
|
||||
}
|
||||
};
|
||||
let (sx, tx) = axis(from.x0, from.x1, to.x0, to.x1);
|
||||
let (sy, ty) = axis(from.y0, from.y1, to.y0, to.y1);
|
||||
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
|
||||
}
|
||||
|
||||
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
|
||||
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
|
||||
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
|
||||
/// as a path does.
|
||||
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
|
||||
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
|
||||
}
|
||||
|
||||
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
|
||||
/// the originals included: each copy adds the object once more, and a Transform after this one
|
||||
/// copies the lot again, so copies stop where the result would pass `max` (#2042).
|
||||
pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
|
||||
let mut room = max.saturating_sub(placed_count(n));
|
||||
copy_placed(n, m, copies, &mut room);
|
||||
}
|
||||
|
||||
/// An object that a Transform effect places through its transform rather than reshapes: an image
|
||||
/// (pixel space to document) or a symbol instance (the symbol's art to document).
|
||||
fn is_placed(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })
|
||||
}
|
||||
|
||||
/// The walk of [`transform_placed`]: the map and its copies go to each image and symbol instance in
|
||||
/// document order, each copy taking one from `room` (what the art can still grow by), until the
|
||||
/// budget is full. As with a path, the copies stop before one reaches past
|
||||
/// [`crate::distort::within_reach`].
|
||||
fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
let Some(children) = n.children_mut() else { return };
|
||||
for c in children.iter_mut() {
|
||||
if !is_placed(c) {
|
||||
copy_placed(Arc::make_mut(c), m, copies, room);
|
||||
continue;
|
||||
}
|
||||
let src = (**c).clone();
|
||||
let placed = |xf: Affine| {
|
||||
let mut one = src.clone();
|
||||
match &mut one.kind {
|
||||
NodeKind::Image(im) => im.xf = xf * im.xf,
|
||||
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
|
||||
_ => {}
|
||||
}
|
||||
one
|
||||
};
|
||||
if copies == 0 {
|
||||
*c = Arc::new(placed(m));
|
||||
continue;
|
||||
}
|
||||
// The copies become nodes of their own, under a group with the object's id, name and place
|
||||
// in the art (as the runs an outlined text gets).
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(placed(Affine::IDENTITY))];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies.min(*room) {
|
||||
xf *= m;
|
||||
let copy = placed(xf);
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
*room -= placements.len() - 1;
|
||||
let mut g = Node::group(src.id, placements);
|
||||
g.name = src.name.clone();
|
||||
*c = Arc::new(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many images and symbol instances `n` holds below itself.
|
||||
fn placed_count(n: &Node) -> usize {
|
||||
n.children().into_iter().flatten().map(|c| usize::from(is_placed(c)) + placed_count(c)).sum()
|
||||
}
|
||||
|
||||
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
|
||||
@@ -216,7 +418,7 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
pub(crate) fn outlined_members(n: &Node) -> Node {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
outline_nested_text(&mut art);
|
||||
outline_nested_text(&mut art, false);
|
||||
art
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
@@ -29,13 +29,17 @@ 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),
|
||||
smooth_shading: true,
|
||||
cap_ends: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
|
||||
return Err("Revolve cap must be a boolean".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -93,7 +100,10 @@ fn surface(
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
@@ -102,13 +112,17 @@ fn surface(
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
|
||||
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
if let Some(g) = face.gradient {
|
||||
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
|
||||
} else {
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
}
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
@@ -119,6 +133,9 @@ fn surface(
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
// Acute face corners must stay within the overlap width. Miter joins
|
||||
// amplify this seam stroke into visible spikes along the silhouette.
|
||||
edge.join = LineJoin::Round;
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -132,6 +149,15 @@ fn surface(
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops =
|
||||
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
@@ -222,6 +248,85 @@ mod tests {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
|
||||
use kurbo::Shape;
|
||||
for shade in [false, true] {
|
||||
for angle in [110.0, 360.0] {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(
|
||||
crate::new_effect(
|
||||
REVOLVE,
|
||||
&json!({
|
||||
"offset":20,"rotationX":-18,"segments":24,"cap":true,
|
||||
"shade":shade,"angle":angle
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
|
||||
let mut checked = 0;
|
||||
art.walk(&mut |face| {
|
||||
let Some(path) = face.path_data() else { return };
|
||||
for item in &face.appearance.items {
|
||||
let AppearanceItem::Stroke(stroke) = item else { continue };
|
||||
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
|
||||
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
|
||||
assert!(
|
||||
bounds.contains_rect(ink),
|
||||
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
});
|
||||
assert!(checked > 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
let smooth = revolve_art(&n).unwrap();
|
||||
assert!(
|
||||
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
|
||||
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
|
||||
})
|
||||
);
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(smooth.clone()));
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(smooth));
|
||||
use vectorcraft_doc::color::{Gradient, GradientPaint};
|
||||
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
n.appearance.set_fill(base.clone());
|
||||
let art = revolve_art(&n).unwrap();
|
||||
let faces = art.children().unwrap()[0].children().unwrap();
|
||||
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
|
||||
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
|
||||
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
|
||||
})));
|
||||
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
|
||||
}
|
||||
#[test]
|
||||
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
|
||||
let mut n = profile_node();
|
||||
let mut effect = fx();
|
||||
effect.params["offset"] = json!(20);
|
||||
effect.params["rotationX"] = json!(-35);
|
||||
effect.params.as_object_mut().unwrap().remove("cap");
|
||||
n.appearance.effects.push(effect);
|
||||
let capped = revolve_art(&n).unwrap();
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
let uncapped = revolve_art(&n).unwrap();
|
||||
assert_ne!(uncapped, capped);
|
||||
n.appearance.effects[0].params["cap"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(capped));
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(uncapped));
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
|
||||
@@ -61,10 +61,12 @@ pub fn view_options(p: &Value) -> View {
|
||||
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
|
||||
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
|
||||
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
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),
|
||||
}
|
||||
@@ -111,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
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, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
||||
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, _) =
|
||||
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")
|
||||
@@ -132,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
facets(id, &path, b, p).map(|_| ())
|
||||
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
|
||||
@@ -183,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
||||
/// 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),
|
||||
_ => path.clone(),
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
|
||||
_ => (path.clone(), rule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
||||
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);
|
||||
@@ -211,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, b, &p).ok()?;
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
}
|
||||
|
||||
|
||||
@@ -116,10 +116,36 @@ 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) => plain_outline(line, &style(st, width), 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
|
||||
|
||||
+127
-23
@@ -3,7 +3,7 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo;
|
||||
use vectorcraft_geom::shapes::{self, KAPPA};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners};
|
||||
|
||||
use crate::util::*;
|
||||
|
||||
@@ -24,15 +24,21 @@ pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`.
|
||||
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. A corner
|
||||
/// whose anchor carries a handle (where a curve meets a line, or two curves meet) gets Live
|
||||
/// Corners' round cut instead ([`corners::cut_corners`], #1144): it meets each side `radius` from
|
||||
/// the corner, measured along the side, and trims a curved side to what is left of it.
|
||||
pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
if radius <= 0.0 {
|
||||
return path.clone();
|
||||
}
|
||||
let mut subs = vec![];
|
||||
// Per anchor of the result: an anchor with a handle, kept as it was, for the cut below.
|
||||
let mut with_handle = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
if n < 3 && (n != 2 || sp.closed) {
|
||||
with_handle.extend(sp.anchors.iter().map(|a| a.has_in() || a.has_out()));
|
||||
subs.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
@@ -41,6 +47,7 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let a = sp.anchors[i];
|
||||
let interior = sp.closed || (i > 0 && i + 1 < n);
|
||||
if !interior || a.has_in() || a.has_out() {
|
||||
with_handle.push(a.has_in() || a.has_out());
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
@@ -52,12 +59,14 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let (vp, vn) = (tp - a.p, tn - a.p);
|
||||
let (lp, ln) = (vp.hypot(), vn.hypot());
|
||||
if lp < 1e-9 || ln < 1e-9 {
|
||||
with_handle.push(false);
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
let (up, un) = (vp / lp, vn / ln);
|
||||
// Nearly straight: nothing to round.
|
||||
if up.dot(un) < -0.9999 {
|
||||
with_handle.push(false);
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
@@ -65,12 +74,36 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let pa = a.p + up * d;
|
||||
let pb = a.p + un * d;
|
||||
let k = d * KAPPA;
|
||||
with_handle.extend([false, false]);
|
||||
out.push(Anchor::with_handles(pa, pa, pa - up * k));
|
||||
out.push(Anchor::with_handles(pb, pb - un * k, pb));
|
||||
}
|
||||
subs.push(SubPath::new(out, sp.closed));
|
||||
}
|
||||
PathData::new(subs)
|
||||
let rounded = PathData::new(subs);
|
||||
// The corners left: those whose anchor has a handle. Their cut reaches `radius` along each
|
||||
// side, as above; setback = r · (1 + cos) / sin, solved for the arc's radius r.
|
||||
let mut radii = vec![0.0; with_handle.len()];
|
||||
for c in corners::path_corners(&rounded) {
|
||||
if with_handle.get(c.index) == Some(&true)
|
||||
&& let Some(r) = radii.get_mut(c.index)
|
||||
{
|
||||
*r = radius * c.sin() / (1.0 + c.cos());
|
||||
}
|
||||
}
|
||||
if radii.iter().all(|r| *r == 0.0) {
|
||||
return rounded;
|
||||
}
|
||||
let (mut out, sources) = corners::cut_corners(&rounded, &radii, &[]);
|
||||
// `cut_corners` starts a closed subpath whose first corner it cuts at the end of that cut;
|
||||
// start at its start instead, as the rounding above does, so what depends on where a path
|
||||
// starts (dashes, arrowheads after the effect) doesn't move.
|
||||
for (sp, from) in out.subpaths.iter_mut().zip(&sources) {
|
||||
if sp.closed && sp.anchors.len() > 1 && from.first() == from.last() {
|
||||
sp.anchors.rotate_right(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn flatten(path: &PathData) -> Vec<Vec<Point>> {
|
||||
@@ -94,15 +127,37 @@ 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. `overlapVariation` moves each
|
||||
/// line's ends along it by up to that many points, `spacingVariation` moves them across it, and
|
||||
/// each turn's curviness moves from `curviness` by up to `curvinessVariation` percent.
|
||||
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);
|
||||
let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0);
|
||||
let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0;
|
||||
let curviness = num(p, "curviness", 5.0).clamp(0.0, 100.0);
|
||||
let curvy = curviness / 100.0;
|
||||
let curvy_var = num(p, "curvinessVariation", 0.0).clamp(0.0, 100.0);
|
||||
let spacing = num(p, "spacing", 5.0).max(0.1);
|
||||
let variation = num(p, "variation", 0.5).clamp(0.0, 100.0);
|
||||
let along = num(p, "overlapVariation", 0.5).clamp(0.0, 100.0);
|
||||
let across = num(p, "spacingVariation", 0.5).clamp(0.0, 100.0);
|
||||
let seed = seed(p);
|
||||
let c = b.center();
|
||||
let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2());
|
||||
@@ -117,7 +172,18 @@ 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>,
|
||||
// Each point's curviness noise in [-1, 1].
|
||||
bends: Vec<f64>,
|
||||
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,31 +197,69 @@ 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 jitter = |k: u64, v: f64| v * noise(seed, row, k, 3);
|
||||
let (l, r) = (Point::new(a + jitter(0, along), y + jitter(1, across)), Point::new(bx + jitter(2, along), y + jitter(3, across)));
|
||||
let even = row.is_multiple_of(2);
|
||||
let pair = if even { [l, r] } else { [r, l] };
|
||||
let bend = [noise(seed, row, 4, 3), noise(seed, row, 5, 3)];
|
||||
let bend = if even { bend } else { [bend[1], bend[0]] };
|
||||
// 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.bends.extend(bend);
|
||||
(c.end, c.row) = (end, row);
|
||||
}
|
||||
None => chains.push(Chain { pts: pair.to_vec(), bends: bend.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_var > 0.0 {
|
||||
varied_curves(&c.pts, &c.bends, curviness, curvy_var)
|
||||
} else 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);
|
||||
PathData::from_bezpath(&outline)
|
||||
}
|
||||
|
||||
/// A scribble line through `pts` whose curviness at each point is `curviness` percent plus
|
||||
/// `variation` percent times that point's `bends` noise (in -1..1), kept within 0 to 100 percent.
|
||||
fn varied_curves(pts: &[Point], bends: &[f64], curviness: f64, variation: f64) -> SubPath {
|
||||
// A tension of 6 makes each handle the whole Catmull-Rom tangent; each point then scales its
|
||||
// own to the tension `catmull_rom` takes for that curviness (curviness / 100 × 2).
|
||||
let mut line = catmull_rom(pts, false, 6.0);
|
||||
for (a, bend) in line.anchors.iter_mut().zip(bends) {
|
||||
let k = (curviness + variation * bend).clamp(0.0, 100.0) / 300.0;
|
||||
a.h_in = a.p + (a.h_in - a.p) * k;
|
||||
a.h_out = a.p + (a.h_out - a.p) * k;
|
||||
}
|
||||
line
|
||||
}
|
||||
+397
-2
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -8,6 +8,26 @@ fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
/// Every image under `n`, in document order (its placement's x).
|
||||
fn image_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
out.push(im.xf.as_coeffs()[4]);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
image_xs(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every symbol instance under `n`, in document order (its placement's x).
|
||||
fn instance_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
|
||||
out.push(xf.as_coeffs()[4]);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
instance_xs(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn square() -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
|
||||
}
|
||||
@@ -206,12 +226,204 @@ 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);
|
||||
}
|
||||
|
||||
/// #2042: stacked Transform effects multiply their image copies as they do their anchors; the
|
||||
/// image nodes they make are bounded, whatever each one's count.
|
||||
#[test]
|
||||
fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = || {
|
||||
Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
)
|
||||
};
|
||||
let count = |art: &Node| {
|
||||
let mut xs = vec![];
|
||||
image_xs(art, &mut xs);
|
||||
xs.len()
|
||||
};
|
||||
let m = Affine::translate((70.0, 0.0));
|
||||
let mut art = Node::group(id, vec![Arc::new(image())]);
|
||||
let mut counts = vec![];
|
||||
for _ in 0..3 {
|
||||
crate::reshape::transform_placed_within(&mut art, m, 1000, 100);
|
||||
counts.push(count(&art));
|
||||
}
|
||||
assert_eq!(counts, [100, 100, 100], "99 copies, 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.
|
||||
let mut art = Node::group(id, vec![Arc::new(image())]);
|
||||
crate::reshape::transform_placed_within(&mut art, m, 24, 100);
|
||||
assert_eq!(count(&art), 25);
|
||||
let mut g = Node::group(id, vec![Arc::new(image())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
|
||||
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
|
||||
}
|
||||
|
||||
/// #2023: a Transform effect on a group moves, scales and copies its images like its paths: the
|
||||
/// map goes into each image's placement, and every copy is an image node of its own.
|
||||
#[test]
|
||||
fn transform_on_a_group_moves_and_copies_its_images() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeId, NodeKind};
|
||||
let id = NodeId(9);
|
||||
let sq = Node::path(id, square(), Appearance::default());
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [10.0, 80.0, 150.0], "the image and its two copies, 70 pt apart");
|
||||
// Without copies the image itself takes the transform, as a path does.
|
||||
let mut g = Node::group(
|
||||
id,
|
||||
vec![Arc::new(Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
))],
|
||||
);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
// The bounds are the image itself, so the bottom-right reference is its own: it halves in place.
|
||||
assert_eq!(xs, [30.0], "scaled about its own bottom-right corner");
|
||||
}
|
||||
|
||||
/// #2023: a Transform effect on a group moves and copies its symbol instances as it does its paths
|
||||
/// and images: the map goes into each instance's placement, and every copy is an instance of its
|
||||
/// own.
|
||||
#[test]
|
||||
fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
let id = NodeId(9);
|
||||
let instance = || Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::translate((5.0, 40.0)) });
|
||||
let xs = |art: &Node| {
|
||||
let mut xs = vec![];
|
||||
instance_xs(art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs
|
||||
};
|
||||
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
|
||||
// Without copies the instance itself takes the transform, as a path does.
|
||||
let mut g = Node::group(id, vec![Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
|
||||
}
|
||||
|
||||
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
|
||||
#[test]
|
||||
fn images_and_symbol_instances_share_the_copy_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
|
||||
let mut art = Node::group(id, vec![Arc::new(image), Arc::new(instance)]);
|
||||
crate::reshape::transform_placed_within(&mut art, Affine::translate((70.0, 0.0)), 1000, 100);
|
||||
let (mut images, mut instances) = (vec![], vec![]);
|
||||
image_xs(&art, &mut images);
|
||||
instance_xs(&art, &mut instances);
|
||||
// The image takes the 98 copies there is room for; none is left for the instance.
|
||||
assert_eq!((images.len(), instances.len()), (99, 1));
|
||||
}
|
||||
|
||||
/// As a path's do, a Transform effect's copies of an image stop before one reaches further than
|
||||
/// `MAX_REACH` from the origin.
|
||||
#[test]
|
||||
fn transform_copies_of_images_stop_at_the_reach_limit() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(image)]);
|
||||
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
|
||||
let mut xs = vec![];
|
||||
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
|
||||
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
|
||||
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pucker_bloat_moves_anchors_in_opposite_directions() {
|
||||
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
|
||||
@@ -236,6 +448,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());
|
||||
@@ -344,6 +594,66 @@ fn round_corners_replaces_corners() {
|
||||
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
|
||||
}
|
||||
|
||||
/// A D: a straight back, a curved bowl, and two corners where the bowl meets the straight
|
||||
/// sides (the anchors there carry the bowl's handles).
|
||||
fn d_shape() -> PathData {
|
||||
use vectorcraft_geom::Anchor;
|
||||
let p = Point::new;
|
||||
PathData::new(vec![SubPath::new(
|
||||
vec![
|
||||
Anchor::corner(p(0.0, 0.0)),
|
||||
Anchor::with_handles(p(50.0, 0.0), p(50.0, 0.0), p(80.0, 20.0)),
|
||||
Anchor::with_handles(p(50.0, 100.0), p(80.0, 80.0), p(50.0, 100.0)),
|
||||
Anchor::corner(p(0.0, 100.0)),
|
||||
],
|
||||
true,
|
||||
)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_also_rounds_where_a_curve_meets_a_line() {
|
||||
let d = d_shape();
|
||||
let out = run("stylize.roundCorners", json!({"radius": 8}), &d);
|
||||
// All four corners are cut, the two curve-line ones too: two anchors each.
|
||||
assert_eq!(out.anchor_count(), 8);
|
||||
assert!(all_finite(&out));
|
||||
// Every cut leaves both sides smoothly: no corner is left anywhere.
|
||||
assert!(vectorcraft_geom::corners::path_corners(&out).is_empty(), "{out:?}");
|
||||
let (before, after) = (d.to_bezpath().area().abs(), out.to_bezpath().area().abs());
|
||||
assert!(after < before && after > before - 4.0 * 8.0 * 8.0, "{before} -> {after}");
|
||||
// The cut at a curve-line corner meets the straight side 8 from the corner.
|
||||
let p = Point::new;
|
||||
assert!(out.subpaths[0].anchors.iter().any(|a| a.p.distance(p(42.0, 0.0)) < 1e-9), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_keeps_straight_corners_as_they_were() {
|
||||
// The D's straight corners are rounded as before, next to the new cuts: same anchors, same
|
||||
// quarter-circle handles, and the path still starts at its first corner's rounding.
|
||||
use vectorcraft_geom::Anchor;
|
||||
let p = Point::new;
|
||||
let k = 8.0 * shapes::KAPPA;
|
||||
let out = stylize::round_corners(&d_shape(), 8.0);
|
||||
let a = &out.subpaths[0].anchors;
|
||||
assert_eq!(a[0], Anchor::with_handles(p(0.0, 8.0), p(0.0, 8.0), p(0.0, 8.0 - k)));
|
||||
assert_eq!(a[1], Anchor::with_handles(p(8.0, 0.0), p(8.0 - k, 0.0), p(8.0, 0.0)));
|
||||
// A path of straight corners only is rounded exactly as before.
|
||||
assert_eq!(stylize::round_corners(&square(), 10.0).subpaths[0].anchors.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_keeps_where_a_closed_path_starts() {
|
||||
// The D starting at a curve-line corner: the result starts where that corner's cut begins,
|
||||
// on the side coming from the previous corner, as a straight corner's rounding does.
|
||||
let mut d = d_shape();
|
||||
d.subpaths[0].anchors.rotate_left(1);
|
||||
let out = stylize::round_corners(&d, 8.0);
|
||||
let start = out.subpaths[0].anchors[0].p;
|
||||
assert!(start.distance(Point::new(42.0, 0.0)) < 1e-9, "{start:?}");
|
||||
// An infinite radius rounds as much as fits, curve-line corners too.
|
||||
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_fixes_the_outer_circle_and_rotates_inside() {
|
||||
let out = run("distort.twist", json!({"angle": 0}), &square());
|
||||
@@ -354,6 +664,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());
|
||||
@@ -363,6 +690,59 @@ fn scribble_produces_filled_outline_inside_bounds() {
|
||||
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_variations_are_separate() {
|
||||
// At 0° the lines run along x: the Path Overlap variation moves their ends in x only, the
|
||||
// Spacing variation moves them in y only, and the Curviness variation curves some of the turns.
|
||||
let base =
|
||||
json!({"angle": 0, "curviness": 0, "strokeWidth": 0.5, "spacing": 10, "overlapVariation": 0, "spacingVariation": 0, "curvinessVariation": 0});
|
||||
let with = |key: &str, v: f64| {
|
||||
let mut p = base.clone();
|
||||
p[key] = json!(v);
|
||||
run("stylize.scribble", p, &square())
|
||||
};
|
||||
let plain = run("stylize.scribble", base.clone(), &square());
|
||||
let b = plain.bounds().unwrap();
|
||||
let moved = |a: f64, b: f64| (a - b).abs() > 0.5;
|
||||
let along = with("overlapVariation", 4.0).bounds().unwrap();
|
||||
assert!(close(along.y0, b.y0, 0.06) && close(along.y1, b.y1, 0.06), "{along:?} {b:?}");
|
||||
assert!(moved(along.x0, b.x0) || moved(along.x1, b.x1), "{along:?} {b:?}");
|
||||
let across = with("spacingVariation", 4.0).bounds().unwrap();
|
||||
assert!(close(across.x0, b.x0, 0.06) && close(across.x1, b.x1, 0.06), "{across:?} {b:?}");
|
||||
assert!(moved(across.y0, b.y0) || moved(across.y1, b.y1), "{across:?} {b:?}");
|
||||
assert_ne!(with("curvinessVariation", 50.0), plain);
|
||||
// On a curviness of its own, the variation changes how much each turn curves.
|
||||
let mut p = base.clone();
|
||||
p["curviness"] = json!(20);
|
||||
let even = run("stylize.scribble", p.clone(), &square());
|
||||
p["curvinessVariation"] = json!(20);
|
||||
assert_ne!(run("stylize.scribble", p, &square()), even);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_reads_its_earlier_single_variation() {
|
||||
// Before the separate variations, one `variation` moved the ends both along and across.
|
||||
let old = run("stylize.scribble", json!({"variation": 3}), &square());
|
||||
assert_eq!(old, run("stylize.scribble", json!({"overlapVariation": 3, "spacingVariation": 3}), &square()));
|
||||
assert_eq!(upgrade_params("stylize.scribble", &json!({"variation": 3})), json!({"overlapVariation": 3, "spacingVariation": 3}));
|
||||
// A current key given with it wins.
|
||||
assert_eq!(
|
||||
upgrade_params("stylize.scribble", &json!({"variation": 3, "spacingVariation": 1})),
|
||||
json!({"overlapVariation": 3, "spacingVariation": 1})
|
||||
);
|
||||
assert!(default_params("stylize.scribble").unwrap().get("variation").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_angle_is_stored_above_minus_180_and_up_to_180() {
|
||||
for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] {
|
||||
assert_eq!(new_effect("stylize.scribble", &json!({"angle": given})).unwrap().params["angle"].as_f64(), Some(stored), "{given}");
|
||||
}
|
||||
// An angle in range is kept as given; other effects' angles are not changed.
|
||||
assert_eq!(upgrade_params("stylize.scribble", &json!({"angle": 45})), json!({"angle": 45}));
|
||||
assert_eq!(upgrade_params("distort.twist", &json!({"angle": 270})), json!({"angle": 270}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_chain_in_order_and_skip_hidden_and_raster() {
|
||||
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
|
||||
@@ -389,11 +769,12 @@ fn raster_effects_parse_and_outset() {
|
||||
let r = raster_effects(&list);
|
||||
assert_eq!(r.len(), 5);
|
||||
match &r[0] {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => {
|
||||
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
|
||||
assert!(close(*opacity as f64, 0.5, 1e-6));
|
||||
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
|
||||
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(*darkness, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
@@ -402,6 +783,20 @@ fn raster_effects_parse_and_outset() {
|
||||
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_darkness_replaces_its_color_when_chosen() {
|
||||
let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() {
|
||||
[RasterFx::DropShadow { darkness, .. }] => *darkness,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6));
|
||||
assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0));
|
||||
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0));
|
||||
// Color is the default: a Darkness value alone leaves the shadow its color.
|
||||
assert_eq!(darkness(json!({"darkness": 60})), None);
|
||||
assert_eq!(darkness(json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mode_names() {
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
|
||||
@@ -146,11 +146,85 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
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, "perspective": 0})));
|
||||
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:?}");
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["bundled-fonts"]
|
||||
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
|
||||
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
@@ -26,6 +32,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 }
|
||||
|
||||
@@ -22,7 +22,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
use super::paint::paint_from;
|
||||
use super::*;
|
||||
@@ -294,15 +294,15 @@ pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
|
||||
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
|
||||
/// among them ([`leaf_targets`]).
|
||||
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
|
||||
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
|
||||
/// objects inside the groups and layers among them ([`leaf_targets`]).
|
||||
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
|
||||
subject_roots(s)?
|
||||
} else {
|
||||
let d = &s.doc()?.doc;
|
||||
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
|
||||
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
|
||||
};
|
||||
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
|
||||
}
|
||||
@@ -560,7 +560,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
if let Some(pa) = &paint {
|
||||
*pp = super::gradient::place_paint(pa, p, bounds);
|
||||
*pp = super::gradient::place_paint(&super::paint::keep_pattern_xf(pa, p, Some(&*pp)), p, bounds);
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
*op = percent(o);
|
||||
@@ -745,7 +745,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
// Reversed, the first selected object's attributes go onto the clicked object.
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{ExportAsset, NodeId};
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
|
||||
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
|
||||
use super::*;
|
||||
|
||||
/// The longest asset name kept (characters).
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Collect for Export",
|
||||
["Object", "Collect for Export"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
|
||||
"{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assets",
|
||||
[],
|
||||
None,
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option<String> {
|
||||
(!n.is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
|
||||
/// (sublayers unfolded). Layers alone have nothing to collect.
|
||||
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if n.is_layer() {
|
||||
if let Some(kids) = n.children() {
|
||||
for c in kids {
|
||||
art_under(doc, c.id, out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push(id);
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.add";
|
||||
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
// An explicit list is authoritative: never silently drop malformed or stale ids.
|
||||
let ids = checked_ids_param(s, p, "ids", C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
roots_of(d, d.paint_order(ids))
|
||||
let mut art = Vec::new();
|
||||
for id in &ids {
|
||||
art_under(d, *id, &mut art);
|
||||
}
|
||||
roots_of(d, d.paint_order(art))
|
||||
} else {
|
||||
selected_roots(s)?
|
||||
};
|
||||
@@ -161,7 +180,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
|
||||
let bounds = export_crop(d, &a.nodes);
|
||||
json!({
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
|
||||
@@ -11,10 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -95,13 +95,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
brush_freehand
|
||||
),
|
||||
// ----- symbols -----
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h]}], current}", has_doc, symbol_list),
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
|
||||
cmd!(
|
||||
"symbol.new",
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?} make a symbol from the selection and replace it with an instance → {name, id}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place Symbol Instance",
|
||||
[],
|
||||
None,
|
||||
"{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}",
|
||||
"{name?, x?, y?} place an instance so its registration point is at (x, y) (default: artboard centre; name default: current) → {id}",
|
||||
has_doc,
|
||||
symbol_place
|
||||
),
|
||||
@@ -198,11 +198,11 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
|
||||
Some(mut v) => {
|
||||
// Explicit ids: only existing, non-layer objects.
|
||||
// Explicit ids: only existing, non-layer objects, none inside another of them.
|
||||
let d = &s.doc()?.doc;
|
||||
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
|
||||
v.dedup();
|
||||
Ok(v)
|
||||
Ok(outermost(d, v))
|
||||
}
|
||||
None => selected_roots(s),
|
||||
}
|
||||
@@ -511,11 +511,19 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance transform for a symbol of natural size `(w, h)` centred at `c`.
|
||||
fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine {
|
||||
/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`.
|
||||
fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
let c = at - reg.offset_from_center(w, h);
|
||||
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
|
||||
}
|
||||
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
|
||||
match p.get("registration") {
|
||||
None => Ok(SymbolRegistration::CENTER),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise art for a symbol definition: returns (art, natural size, centre in the document).
|
||||
fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?;
|
||||
@@ -553,7 +561,12 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|sym| {
|
||||
let (w, h) = natural_size(d, &sym.name);
|
||||
json!({"name": sym.name, "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h]})
|
||||
json!({
|
||||
"name": sym.name,
|
||||
"instances": instances_of(d, Some(&sym.name)).len(),
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
|
||||
@@ -580,16 +593,20 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, "symbol.new")?;
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0 }))
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
|
||||
}
|
||||
|
||||
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
|
||||
@@ -606,12 +623,13 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let name = resolve_symbol(d, p, "symbol.place")?;
|
||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Place Symbol Instance", |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
|
||||
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -720,7 +738,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*xf *= fix;
|
||||
}
|
||||
}
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
|
||||
let reg = d.symbol_registration(&name);
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -743,6 +762,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -758,10 +778,12 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new }))
|
||||
@@ -882,13 +904,14 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
placed.push(c);
|
||||
}
|
||||
let count = placed.len();
|
||||
let reg = s.doc()?.doc.symbol_registration(&name);
|
||||
let id = s.edit("Symbol Sprayer", |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for (i, c) in placed.iter().enumerate() {
|
||||
let (_, _, js) = jitter(i + 7, *c);
|
||||
let k = scale * (1.0 + js * 0.1);
|
||||
let id = d.alloc_id();
|
||||
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size) * Affine::scale(k) }));
|
||||
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) }));
|
||||
}
|
||||
let gid = match set {
|
||||
Some(g) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -24,7 +24,7 @@ use vectorcraft_tools::builder::{
|
||||
};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -192,7 +192,7 @@ fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select objects, not layers".into()));
|
||||
}
|
||||
Ok(sorted_roots(d, &ids))
|
||||
Ok(outermost(d, sorted_roots(d, &ids)))
|
||||
}
|
||||
|
||||
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
|
||||
@@ -657,7 +657,10 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
|
||||
@@ -703,17 +706,93 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
|
||||
use vectorcraft_geom::Affine;
|
||||
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
|
||||
let mut live = match shape {
|
||||
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
|
||||
w: width,
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
let _ = live.transform(xf);
|
||||
live
|
||||
}
|
||||
|
||||
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
|
||||
/// document by `xf`.
|
||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
|
||||
/// abutting paths painted in another order would show a seam where they meet. Without parents,
|
||||
/// the nodes as they are.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut roots = vec![];
|
||||
for i in 0..n {
|
||||
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
|
||||
Some(of) => of.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
// Outermost first, so that backwards each path comes after those inside it.
|
||||
let mut order = roots.clone();
|
||||
let mut at = 0;
|
||||
while let Some(&i) = order.get(at) {
|
||||
order.extend(inside.get(i).into_iter().flatten());
|
||||
at += 1;
|
||||
}
|
||||
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
|
||||
let made = if within.is_empty() {
|
||||
node
|
||||
} else {
|
||||
within.push((i, Arc::new(node)));
|
||||
within.sort_by_key(|m| m.0);
|
||||
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
|
||||
};
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
}
|
||||
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
|
||||
// and they are kept all the same.)
|
||||
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
let preset_label = match tr::preset(named) {
|
||||
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(¶ms_json) => named.to_string(),
|
||||
_ => "Custom".to_string(),
|
||||
};
|
||||
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json);
|
||||
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
@@ -763,17 +842,25 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
|
||||
let shape = traced_shape(shape, xf);
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
@@ -782,8 +869,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
.into(),
|
||||
None => fill_only(paint).into(),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
traced.push(n);
|
||||
}
|
||||
children.extend(grouped(d, traced, &res.parents));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
|
||||
@@ -80,7 +80,8 @@ pub struct Clipboard {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Brushes the objects' strokes use.
|
||||
pub brushes: Vec<Brush>,
|
||||
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
|
||||
/// Artboards keeps their offset to it.
|
||||
pub source_artboard: Option<Rect>,
|
||||
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
||||
pub source_layers: Vec<Option<String>>,
|
||||
@@ -109,8 +110,14 @@ impl Clipboard {
|
||||
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
||||
}
|
||||
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
|
||||
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
|
||||
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center()));
|
||||
Self {
|
||||
source_artboard: board.and_then(|i| d.artboards.get(i)).map(|a| a.rect),
|
||||
source_layers,
|
||||
source_doc: Some(st.uid),
|
||||
..Self::gather(d, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
|
||||
@@ -168,15 +175,13 @@ impl Clipboard {
|
||||
d.fix_next_id();
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
if self.clipboard.is_empty() {
|
||||
/// The objects as standalone SVG (`None` when there are none).
|
||||
pub fn svg(&self) -> Option<String> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = self.clipboard.to_document();
|
||||
let d = self.to_document();
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = super::rasterfx::flatten_pixel_effects(&d);
|
||||
Some(vectorcraft_svg::export(
|
||||
@@ -186,6 +191,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
self.clipboard.svg()
|
||||
}
|
||||
}
|
||||
|
||||
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||
}
|
||||
|
||||
@@ -71,13 +71,19 @@ pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
|
||||
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
// `detect` names gzip data SVGZ (a compressed native file aside); `clipboard.importSvg`
|
||||
// reads SVGZ data.
|
||||
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -85,6 +91,25 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
mimes.contains(&asked).then_some(mime)
|
||||
}
|
||||
|
||||
/// Does gzip data `head` (a file's first bytes, often a stream cut short) unpack to SVG markup?
|
||||
/// The first [`FILE_HEAD`] bytes it unpacks to are read as UTF-16 after a UTF-16 byte order mark,
|
||||
/// else as UTF-8, and checked with [`looks_like_svg`].
|
||||
fn unpacks_to_svg(head: &[u8]) -> bool {
|
||||
use std::io::Read as _;
|
||||
let mut raw = Vec::with_capacity(FILE_HEAD);
|
||||
// A stream cut short ends in an error after the bytes that unpacked: judge those.
|
||||
let _ = flate2::read::GzDecoder::new(head).take(FILE_HEAD as u64).read_to_end(&mut raw);
|
||||
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| -> String {
|
||||
char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|u| unit(*u))).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
|
||||
};
|
||||
let text = match raw.as_slice() {
|
||||
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
|
||||
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
|
||||
_ => String::from_utf8_lossy(&raw).into_owned(),
|
||||
};
|
||||
looks_like_svg(&text)
|
||||
}
|
||||
|
||||
/// One representation of the clipboard's contents.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Flavour {
|
||||
@@ -192,11 +217,10 @@ fn type_only(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
|
||||
/// only).
|
||||
pub fn clipboard_text(&self) -> Option<String> {
|
||||
let nodes = &self.clipboard.nodes;
|
||||
impl Clipboard {
|
||||
/// The copied type objects' text, one object per line (`None` unless it holds type only).
|
||||
pub fn text(&self) -> Option<String> {
|
||||
let nodes = &self.nodes;
|
||||
if nodes.is_empty() || !nodes.iter().all(type_only) {
|
||||
return None;
|
||||
}
|
||||
@@ -211,64 +235,94 @@ impl Session {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
|
||||
/// it is empty or has no extent).
|
||||
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
|
||||
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
|
||||
/// The objects as a document whose one artboard is their visual bounds (`None` when there are
|
||||
/// none or they have no extent).
|
||||
fn cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let b = self.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
|
||||
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
|
||||
/// when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
|
||||
/// there are none.
|
||||
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
const C: &str = "clipboard.exportPng";
|
||||
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
let Some((doc, b)) = self.cropped() else { return Ok(None) };
|
||||
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
|
||||
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h)))
|
||||
}
|
||||
|
||||
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
|
||||
/// clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
/// A one-page PDF of the objects (without the native document inside); `None` when there are
|
||||
/// none.
|
||||
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
let Some((doc, _)) = self.cropped() else { return Ok(None) };
|
||||
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::clipboard_flavours`], without making them.
|
||||
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
|
||||
if self.clipboard.is_empty() {
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
|
||||
if self.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
|
||||
let text = svg || self.clipboard.nodes.iter().all(type_only);
|
||||
let text = svg || self.nodes.iter().all(type_only);
|
||||
// Art without an area (a lone guide-like line) has no page or picture.
|
||||
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
|
||||
}
|
||||
|
||||
/// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be
|
||||
/// made (art too large to render) is left out; the PNG is scaled down to at most
|
||||
/// [`COPY_PNG_SIDE`] pixels a side.
|
||||
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
|
||||
let types = self.clipboard_flavour_types();
|
||||
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
|
||||
/// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF
|
||||
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
|
||||
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
|
||||
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
|
||||
let types = self.flavour_types(svg, pdf);
|
||||
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
|
||||
let mut out = vec![];
|
||||
for mime in types {
|
||||
let data = match mime {
|
||||
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
SVG => svg.clone().map(String::into_bytes),
|
||||
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
_ => {
|
||||
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
|
||||
self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
|
||||
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
|
||||
}
|
||||
};
|
||||
out.extend(data.map(|data| Flavour { mime, data }));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
|
||||
/// only).
|
||||
pub fn clipboard_text(&self) -> Option<String> {
|
||||
self.clipboard.text()
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
|
||||
/// when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
self.clipboard.png(scale)
|
||||
}
|
||||
|
||||
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
|
||||
/// clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
self.clipboard.pdf()
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::clipboard_flavours`], without making them.
|
||||
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
|
||||
self.clipboard.flavour_types(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
|
||||
}
|
||||
|
||||
/// What Copy offers other apps, best first (see `clipboard.flavours`): [`Clipboard::flavours`]
|
||||
/// by the Clipboard Handling preferences.
|
||||
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
|
||||
self.clipboard.flavours(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
|
||||
/// artboard's centre) → the number of objects.
|
||||
|
||||
@@ -204,6 +204,12 @@ impl<'a> Recolor<'a> {
|
||||
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
|
||||
}
|
||||
|
||||
/// A pass that maps solid colours and gradient stops with their swatch link and tint
|
||||
/// (`links`), and meshes, images and patterns through `f`.
|
||||
pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self {
|
||||
Self { links: Some(links), ..Self::new(scope, f) }
|
||||
}
|
||||
|
||||
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
|
||||
/// pattern swatches, and return the number of paints, meshes and images changed.
|
||||
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}",
|
||||
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proof Setup",
|
||||
["View", "Proof Setup"],
|
||||
None,
|
||||
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
|
||||
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\"|\"tritanopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
|
||||
always,
|
||||
proof_setup
|
||||
),
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -95,6 +95,12 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
|
||||
|
||||
/// Insert a new object looking like `look` (drawing modes apply); select it.
|
||||
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
|
||||
insert_look(s, label, kind, look, name, true)
|
||||
}
|
||||
|
||||
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
|
||||
/// stays as it was.
|
||||
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let mode = s.draw_mode;
|
||||
let inside = s.draw_inside;
|
||||
@@ -137,7 +143,9 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
}
|
||||
}
|
||||
sel.set([id]);
|
||||
if select {
|
||||
sel.set([id]);
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
@@ -519,12 +527,16 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
|
||||
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
|
||||
/// The paragraph alignment, leading model and character alignment new type starts with (`t`'s
|
||||
/// paragraph and runs): `justify` from `p` (else as [`new_type_para`] left it), `leadingModel`
|
||||
/// and `charAlign` from `p`, else em box top to top and em box centre while the
|
||||
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
|
||||
/// replay sets the same whatever the interface.
|
||||
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
|
||||
use vectorcraft_doc::{CharAlign, LeadingModel};
|
||||
if let Some(j) = str_param(p, "justify") {
|
||||
t.para.justify = super::typecmd::justify_param(j).ok_or_else(|| bad(cmd, "justify must be auto|left|center|right|justifyAll"))?;
|
||||
}
|
||||
let japanese = s.japanese_interface();
|
||||
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
|
||||
Some(v) => match v.as_str() {
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"dimension.create",
|
||||
"Create Dimension",
|
||||
[],
|
||||
None,
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"dimension.update",
|
||||
"Update Dimensions",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
cmd!(
|
||||
"dimension.applyToAll",
|
||||
"Apply to All",
|
||||
[],
|
||||
None,
|
||||
"{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
|
||||
has_doc,
|
||||
apply_to_all
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn error(s: impl ToString) -> EngineError {
|
||||
EngineError::Other(s.to_string())
|
||||
}
|
||||
|
||||
fn options(s: &Session, p: &Value) -> Result<DimensionOptions> {
|
||||
let default = s.tool_options_of("dimension");
|
||||
let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({})));
|
||||
DimensionOptions::from_params(value).map_err(error)
|
||||
}
|
||||
|
||||
fn point(p: &Value, key: &str) -> Result<Point> {
|
||||
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?;
|
||||
let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
|
||||
let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
|
||||
Ok(Point::new(x, y))
|
||||
}
|
||||
|
||||
fn distance(p: &Value, fallback: f64) -> Result<f64> {
|
||||
match p.get("offset") {
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")),
|
||||
None => Ok(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
|
||||
let mode: DimensionMode =
|
||||
p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
|
||||
let source: Option<DimensionSource> = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
|
||||
if let Some(source) = &source {
|
||||
let doc = &s.doc()?.doc;
|
||||
for id in source.nodes() {
|
||||
if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) {
|
||||
return Err(error("Choose original artwork, not a dimension's generated paths"));
|
||||
}
|
||||
}
|
||||
let compatible = matches!(
|
||||
(mode, source),
|
||||
(DimensionMode::Linear, DimensionSource::Segment { .. })
|
||||
| (DimensionMode::Angular, DimensionSource::Angle { .. })
|
||||
| (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
|
||||
| (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
|
||||
);
|
||||
if !compatible {
|
||||
return Err(error("Dimension mode does not match its source"));
|
||||
}
|
||||
}
|
||||
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
|
||||
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
|
||||
let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
|
||||
point(p, "vertex")?
|
||||
} else {
|
||||
Point::ZERO
|
||||
};
|
||||
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
|
||||
d.resolve(&s.doc()?.doc).map_err(error)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
|
||||
let id = doc.alloc_id();
|
||||
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
|
||||
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
|
||||
doc.dimensions = true;
|
||||
group.extras_mut().dimension = Some(Box::new(dim));
|
||||
doc.insert(parent, usize::MAX, group)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dim = recipe(s, p)?;
|
||||
let label = dim.label().map_err(error)?;
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?);
|
||||
let id = s.edit("Create Dimension", |doc, sel| {
|
||||
let id = insert(doc, parent, dim)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"id":id.0,"label":label}))
|
||||
}
|
||||
|
||||
fn dimension_of(doc: &Document, id: NodeId) -> Result<Dimension> {
|
||||
doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = match p.get("ids") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
|
||||
None => s.doc()?.selection.objects.clone(),
|
||||
};
|
||||
if ids.is_empty() || ids.len() > 10_000 {
|
||||
return Err(error("Select dimensions to update"));
|
||||
}
|
||||
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
for id in &ids {
|
||||
if !doc.is_editable(*id) {
|
||||
return Err(error("Dimension is locked or hidden"));
|
||||
}
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
if let Some(o) = &o {
|
||||
dim.options = o.clone();
|
||||
}
|
||||
dim.offset = distance(p, dim.offset)?;
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let from = match p.get("id") {
|
||||
Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
|
||||
None => *st
|
||||
.selection
|
||||
.objects
|
||||
.iter()
|
||||
.find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
|
||||
.ok_or_else(|| error("Select a dimension first"))?,
|
||||
};
|
||||
let o = dimension_of(&st.doc, from)?.options;
|
||||
let mut ids = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
s.edit("Apply to All", |doc, _| {
|
||||
for id in &ids {
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
dim.options = o.clone();
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
|
||||
let children = artwork(doc, &dim, &old)?;
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.kind = NodeKind::Group { children, clip: false };
|
||||
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
n.extras_mut().dimension = Some(Box::new(dim));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
if !doc.dimensions {
|
||||
return Ok(());
|
||||
}
|
||||
let mut dimensions = Vec::new();
|
||||
doc.walk(|n| {
|
||||
if let Some(d) = &n.extras().dimension {
|
||||
dimensions.push((n.id, (**d).clone()));
|
||||
}
|
||||
});
|
||||
doc.dimensions = !dimensions.is_empty();
|
||||
for (id, old) in dimensions {
|
||||
let mut next = old.clone();
|
||||
let moved = next.moved.take();
|
||||
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
|
||||
// A deleted/degenerate source has no valid measurement. Remove its annotation in
|
||||
// this same undo step instead of keeping a silently wrong number.
|
||||
doc.remove(id)?;
|
||||
continue;
|
||||
}
|
||||
// Moved on its own (the art it measures stayed put): it keeps its new place.
|
||||
if let Some(v) = moved
|
||||
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
|
||||
{
|
||||
next.place(next.handle() + v);
|
||||
if next.resolve(doc).is_err() {
|
||||
next = old.clone();
|
||||
next.moved = None;
|
||||
}
|
||||
}
|
||||
if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
|
||||
replace(doc, id, next)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paint(c: [u8; 3]) -> Paint {
|
||||
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
|
||||
}
|
||||
fn line(a: Point, b: Point) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
p
|
||||
}
|
||||
|
||||
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
|
||||
let (color, width, kind) =
|
||||
if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
|
||||
let mut a = Appearance::basic(Paint::None, paint(color), width);
|
||||
if let Some(st) = a.stroke_mut() {
|
||||
st.dash = match kind {
|
||||
LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
|
||||
LineType::Dotted => {
|
||||
st.cap = vectorcraft_doc::LineCap::Round;
|
||||
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
|
||||
}
|
||||
LineType::Solid => None,
|
||||
};
|
||||
if arrows {
|
||||
let head = match o.arrow_style {
|
||||
ArrowStyle::Filled => Some(Arrowhead::Triangle),
|
||||
ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
|
||||
ArrowStyle::Tick => Some(Arrowhead::Slash),
|
||||
ArrowStyle::None => None,
|
||||
};
|
||||
st.start_arrow = head;
|
||||
st.end_arrow = head;
|
||||
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
|
||||
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
|
||||
fn along(o: &DimensionOptions) -> f64 {
|
||||
match o.text_alignment {
|
||||
TextAlignment::Left => 0.2,
|
||||
TextAlignment::Center => 0.5,
|
||||
TextAlignment::Right => 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
|
||||
d.validate().map_err(error)?;
|
||||
let o = &d.options;
|
||||
let label = d.label().map_err(error)?;
|
||||
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
|
||||
let mut angle = 0.0;
|
||||
let text_at;
|
||||
// Away from the object: past the dimension line; between: on the object's side of it.
|
||||
let away = o.text_position == TextPosition::Away;
|
||||
if d.mode == DimensionMode::Angular {
|
||||
let start = (d.a - d.vertex).atan2();
|
||||
let sweep = d.angle();
|
||||
let r = d.offset.abs().max(12.0);
|
||||
let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r;
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to(on(0.0));
|
||||
for i in 1..=64 {
|
||||
arc.line_to(on(i as f64 / 64.0));
|
||||
}
|
||||
paths.push((arc, appearance(o, false, true)));
|
||||
if !o.hide_extensions {
|
||||
for p in [d.a, d.b] {
|
||||
let dir = (p - d.vertex) / (p - d.vertex).hypot();
|
||||
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
|
||||
}
|
||||
}
|
||||
let dir = Vec2::from_angle(start + sweep * along(o));
|
||||
let gap = o.text_offset + o.font_size * 0.5;
|
||||
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
|
||||
} else {
|
||||
let delta = d.b - d.a;
|
||||
let u = delta / delta.hypot();
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let (a, b) = match d.mode {
|
||||
DimensionMode::Radius => (d.a, d.b),
|
||||
DimensionMode::Diameter => (d.a - delta, d.b),
|
||||
_ => (d.a + n * d.offset, d.b + n * d.offset),
|
||||
};
|
||||
// The side of the line facing away from the object.
|
||||
let outward = match d.mode {
|
||||
DimensionMode::Linear => {
|
||||
if d.offset < 0.0 {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
_ => n,
|
||||
};
|
||||
if d.mode == DimensionMode::Linear && !o.hide_extensions {
|
||||
for (p, q) in [(d.a, a), (d.b, b)] {
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
|
||||
}
|
||||
}
|
||||
angle = u.atan2();
|
||||
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
|
||||
angle += std::f64::consts::PI;
|
||||
}
|
||||
let shift = o.text_offset + o.font_size * 0.5;
|
||||
text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
|
||||
paths.push((line(a, b), appearance(o, false, true)));
|
||||
}
|
||||
let style = CharStyle {
|
||||
font_family: o.font_family.clone(),
|
||||
font_style: o.font_style.clone(),
|
||||
size: o.font_size,
|
||||
fill: paint(o.text_color),
|
||||
..Default::default()
|
||||
};
|
||||
let mut text = TextObject::point(Point::ZERO, &label, style);
|
||||
text.para.justify = vectorcraft_doc::Justify::Center;
|
||||
super::typecmd::refresh_bounds(&mut text);
|
||||
// Centre the label's box on its anchor point, along the line.
|
||||
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
|
||||
let middle = Affine::translate(-bounds.center().to_vec2());
|
||||
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle;
|
||||
let mut children = Vec::new();
|
||||
for (path, a) in paths {
|
||||
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
|
||||
children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a)));
|
||||
}
|
||||
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
|
||||
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
|
||||
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
|
||||
children.push(Arc::new(node));
|
||||
Ok(children)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
|
||||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::edit::{duplicate_in, outermost, selected_roots};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Width Point",
|
||||
[],
|
||||
None,
|
||||
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
|
||||
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (the nearest points either side change their widths in proportion, using the sampled widths for a new point)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
|
||||
has_doc,
|
||||
width_point_set
|
||||
),
|
||||
@@ -257,7 +257,22 @@ fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below:
|
||||
/// points at other positions either side change in proportion (by the same amount from zero).
|
||||
fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) {
|
||||
let t = points[i].0;
|
||||
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0);
|
||||
let scale = |v: f64, o: f64, n: f64| {
|
||||
let width = if o > 1e-9 {
|
||||
let product = v * n;
|
||||
if product.is_finite() {
|
||||
// Keep the existing rounding, including small products that n/o would lose.
|
||||
product / o
|
||||
} else {
|
||||
// With finite nonnegative factors, an overflowing product has both factors
|
||||
// above one. Divide the larger first so this fallback cannot underflow.
|
||||
(v.max(n) / o) * v.min(n)
|
||||
}
|
||||
} else {
|
||||
v + n - o
|
||||
};
|
||||
width.max(0.0)
|
||||
};
|
||||
let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
for j in before.into_iter().chain(after) {
|
||||
@@ -293,8 +308,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let half = st.width / 2.0;
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let pt = (t, left / half, right / half);
|
||||
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
pos = match point_index(p, &prof, C)? {
|
||||
Some(i) => {
|
||||
let old = prof.points[i];
|
||||
@@ -311,10 +329,20 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let old = adjust.then(|| prof.at(t));
|
||||
prof.points.retain(|q| (q.0 - t).abs() > SAME_T);
|
||||
place(&mut prof.points, pt, true)
|
||||
let i = place(&mut prof.points, pt, true);
|
||||
if let Some((left, right)) = old {
|
||||
adjust_adjoining(&mut prof.points, i, (t, left, right), pt);
|
||||
}
|
||||
i
|
||||
}
|
||||
};
|
||||
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
|
||||
// the document, its dirty state or either side of its history.
|
||||
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
st.profile = Some(prof);
|
||||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||||
})?;
|
||||
@@ -678,7 +706,8 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
|
||||
return Err(bad(C, "pins must be finite"));
|
||||
}
|
||||
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
|
||||
let ids =
|
||||
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
|
||||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||||
let angles: Vec<Option<f64>> = match p.get("angles") {
|
||||
None | Some(Value::Null) => vec![None; pins.len()],
|
||||
@@ -818,7 +847,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
|
||||
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
|
||||
Some(v) => Ok(v),
|
||||
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
|
||||
None => selected_roots(s),
|
||||
}
|
||||
}
|
||||
@@ -908,9 +937,18 @@ fn beyond_horizon() -> EngineError {
|
||||
EngineError::Other("the pointer is beyond the plane's horizon".into())
|
||||
}
|
||||
|
||||
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
|
||||
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
|
||||
fn stores_attachments(d: &Document) -> bool {
|
||||
!PerspectiveGrid::current(d).attached.is_empty()
|
||||
}
|
||||
|
||||
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
|
||||
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
|
||||
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
|
||||
/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an
|
||||
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
|
||||
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
|
||||
/// own ([`stores_attachments`]).
|
||||
fn persp_edit(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
@@ -923,9 +961,20 @@ fn persp_edit(
|
||||
return Err(EngineError::Other("select the objects to transform in perspective".into()));
|
||||
}
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None };
|
||||
s.edit(label, |d, sel| {
|
||||
g.adopt_stored_attachments(d);
|
||||
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
|
||||
let targets = match &kept {
|
||||
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
|
||||
// below maps only the copies.
|
||||
Some(k) => {
|
||||
*d = Document::clone(&k.doc);
|
||||
sel.clone_from(&k.selection);
|
||||
k.ids.clone()
|
||||
}
|
||||
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
|
||||
None => ids.to_vec(),
|
||||
};
|
||||
let mut first = None;
|
||||
for id in &targets {
|
||||
let (plane, depth) = match g.attachment_of(d, *id) {
|
||||
@@ -1111,12 +1160,32 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
// In an interaction whose document is still its snapshot, the copies of the objects on the
|
||||
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
|
||||
// lists attachments of its own (`stores_attachments`).
|
||||
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
|
||||
let on = on_plane(&s.doc()?.doc);
|
||||
super::object::preview_copies(s, &on, false)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
old.adopt_stored_attachments(d);
|
||||
let mut ids = vec![];
|
||||
if objects != "none" {
|
||||
let on = on_plane(d);
|
||||
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
|
||||
ids = match &kept {
|
||||
// The document becomes the snapshot with the kept copies, not yet moved, and the
|
||||
// loop below moves only the copies.
|
||||
Some(k) => {
|
||||
*d = Document::clone(&k.doc);
|
||||
sel.clone_from(&k.selection);
|
||||
k.ids.clone()
|
||||
}
|
||||
None => {
|
||||
let on = on_plane(d);
|
||||
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
|
||||
}
|
||||
};
|
||||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||||
for id in &ids {
|
||||
warp_checked(d, *id, &m)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -50,7 +50,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
const OBJECT_LABELS: [(&str, &str); 18] = [
|
||||
("paths", "Paths"),
|
||||
("compoundPaths", "Compound Paths"),
|
||||
("groups", "Groups"),
|
||||
@@ -65,6 +65,7 @@ const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
("blends", "Blends"),
|
||||
("envelopes", "Envelopes"),
|
||||
("repeats", "Repeats"),
|
||||
("intertwines", "Intertwine Groups"),
|
||||
("opacityMasks", "Opacity Masks"),
|
||||
("liveEffects", "Objects with Effects"),
|
||||
("guides", "Guides"),
|
||||
@@ -128,7 +129,13 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
|
||||
rows
|
||||
}
|
||||
"objects" => {
|
||||
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect()
|
||||
let mut rows: Vec<(String, String)> =
|
||||
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect();
|
||||
// The paths' total length, in the document's units, right after their count.
|
||||
if let Some(at) = rows.iter().position(|(label, _)| label == "Paths") {
|
||||
rows.insert(at + 1, ("Path Length".into(), u.format(info["pathLength"].as_f64().unwrap_or(0.0))));
|
||||
}
|
||||
rows
|
||||
}
|
||||
"graphicStyles" => names("graphicStyleNames"),
|
||||
"spotColors" => names("spotColors"),
|
||||
@@ -229,6 +236,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots: Vec<&Node> =
|
||||
if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
// The paths' total length, as Document Info › Objects shows it (the length of a cut line, a
|
||||
// strip of LEDs along the letters, #1958).
|
||||
let mut path_length = 0.0;
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
@@ -243,7 +253,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Group { clip: true, .. } => Some("clipGroups"),
|
||||
NodeKind::Group { .. } => Some("groups"),
|
||||
NodeKind::Path { guide: true, .. } => Some("guides"),
|
||||
NodeKind::Path { .. } => Some("paths"),
|
||||
NodeKind::Path { path, .. } => {
|
||||
let l = path.length();
|
||||
if l.is_finite() {
|
||||
path_length += l;
|
||||
}
|
||||
Some("paths")
|
||||
}
|
||||
NodeKind::Compound { .. } => Some("compoundPaths"),
|
||||
NodeKind::Text(t) => {
|
||||
for run in &t.runs {
|
||||
@@ -269,6 +285,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Mesh(_) => Some("meshes"),
|
||||
NodeKind::Repeat(_) => Some("repeats"),
|
||||
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
|
||||
NodeKind::Intertwine { .. } => Some("intertwines"),
|
||||
};
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
@@ -308,6 +325,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
"pathLength": path_length,
|
||||
"fonts": font_names,
|
||||
"images": images,
|
||||
"symbols": symbols,
|
||||
@@ -354,7 +372,8 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
|
||||
}
|
||||
|
||||
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? };
|
||||
let ids: Vec<NodeId> =
|
||||
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
|
||||
let n = ids.len();
|
||||
s.edit("Make Pixel Perfect", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -405,6 +424,30 @@ mod tests {
|
||||
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
|
||||
}
|
||||
|
||||
/// Document Info › Objects gives the paths' total length in the document's units (#1958): an
|
||||
/// open line, a closed rectangle's perimeter and a compound path's pieces count, a guide doesn't.
|
||||
#[test]
|
||||
fn info_gives_the_paths_total_length() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400, "units": "Millimeters"})).unwrap();
|
||||
let line = s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 72, "y2": 0})).unwrap()["id"].clone();
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 10, "height": 20})).unwrap()["id"].clone();
|
||||
let length = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap()["pathLength"].as_f64().unwrap();
|
||||
assert!((length(&mut s, json!({})) - 132.0).abs() < 1e-6);
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
assert!((length(&mut s, json!({"selectionOnly": true})) - 72.0).abs() < 1e-6);
|
||||
let rows = s.execute("document.info", &json!({"selectionOnly": true, "category": "objects"})).unwrap()["sections"][0]["rows"].clone();
|
||||
assert_eq!(rows, json!([["Paths", "1"], ["Path Length", "25.4 mm"]]));
|
||||
// A compound path's pieces are paths: their lengths count.
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 5, "height": 5})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [rect, other]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
assert!((length(&mut s, json!({"selectionOnly": true})) - 80.0).abs() < 1e-6);
|
||||
// Nothing with no paths, and no length row.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_permissions_read_from_fs_type() {
|
||||
assert_eq!(embedding_label(0), "embedding allowed");
|
||||
|
||||
@@ -659,6 +659,17 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("guide.move", "no guide selected (or give an index)"));
|
||||
}
|
||||
let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false));
|
||||
if !s.doc()?.selection.is_empty() {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
return super::object::apply_transform(
|
||||
s,
|
||||
if copy { "Copy Guide" } else { "Move Guide" },
|
||||
ids,
|
||||
vectorcraft_geom::Affine::translate((dx, dy)),
|
||||
p,
|
||||
true,
|
||||
);
|
||||
}
|
||||
s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| {
|
||||
let mut copies = vec![];
|
||||
for &i in &moving {
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node};
|
||||
use super::create::{add_look, add_node, insert_look};
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Curvature",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
has_doc,
|
||||
curvature_edit
|
||||
),
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)",
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -544,6 +544,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
let keep_selected = bool_or(p, "keepSelected", true);
|
||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let at_start = str_param(ext, "end") == Some("start");
|
||||
@@ -605,7 +606,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
|
||||
}
|
||||
sel.set([id]);
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
@@ -615,11 +620,14 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let sp = fit_freehand(&pts, tol, closed);
|
||||
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
|
||||
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
|
||||
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
|
||||
// gets black. A brush is its stroke.
|
||||
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
|
||||
let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1));
|
||||
if let Some(st) = look.appearance.stroke_mut() {
|
||||
st.pressure = pressure;
|
||||
}
|
||||
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
|
||||
insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)
|
||||
}
|
||||
|
||||
// ---------- Curvature ----------
|
||||
@@ -640,7 +648,7 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false));
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
@@ -685,6 +693,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => EndCurve::Keep,
|
||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||
};
|
||||
let corner = bool_or(p, "corner", false);
|
||||
let op = match str_param(p, "op") {
|
||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||
@@ -709,7 +718,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ai
|
||||
}
|
||||
CurvatureOp::Extend(q) => {
|
||||
curvature_extend(sp, start, q, how);
|
||||
curvature_extend(sp, start, q, how, corner);
|
||||
if start { 0 } else { sp.anchors.len() - 1 }
|
||||
}
|
||||
CurvatureOp::Close => {
|
||||
@@ -816,14 +825,19 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Convert Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if smooth && target.is_none() {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
// Without a handle position the anchor curves as the Curvature tool curves a point; one
|
||||
// with no direction to take stays as it is.
|
||||
vectorcraft_tools::draw2::curvature_smooth(sp, ai);
|
||||
return Ok(());
|
||||
}
|
||||
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
match target {
|
||||
_ if !smooth => a.retract(),
|
||||
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
Some(_) => a.retract(),
|
||||
None => {
|
||||
sp.smooth_anchor(ai);
|
||||
}
|
||||
Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
_ => a.retract(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Loaded 100 of 616 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user