mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 12:16:54 -04:00
Compare commits
691
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc6532928 | ||
|
|
4791c88809 | ||
|
|
06b33b0d2d | ||
|
|
4664907fdc | ||
|
|
35b9d5a645 | ||
|
|
18b472092e | ||
|
|
32a3362e7b | ||
|
|
4bb469563b | ||
|
|
d59733937a | ||
|
|
1799fa533e | ||
|
|
b32017d44f | ||
|
|
0c9b65c931 | ||
|
|
2d230dc31a | ||
|
|
c06c794e5a | ||
|
|
143e4d78e7 | ||
|
|
cb3915d230 | ||
|
|
93913c7906 | ||
|
|
c459285f42 | ||
|
|
e31a39ec40 | ||
|
|
36deb67f20 | ||
|
|
fec2ac402b | ||
|
|
8593c15b90 | ||
|
|
6d4ad17954 | ||
|
|
c401df19d0 | ||
|
|
743801b255 | ||
|
|
f39b01174c | ||
|
|
0f786df95e | ||
|
|
4ee4bff9b6 | ||
|
|
233ad71bd6 | ||
|
|
0e97cf1d4b | ||
|
|
830dea80e0 | ||
|
|
6fc79c33b7 | ||
|
|
24583effe0 | ||
|
|
d21040d746 | ||
|
|
9e44139641 | ||
|
|
314bfe802f | ||
|
|
b795d15d69 | ||
|
|
05b160b32e | ||
|
|
84bd59de36 | ||
|
|
065e237f7f | ||
|
|
adea2d307d | ||
|
|
dfe215ca30 | ||
|
|
bc8e42d8c7 | ||
|
|
d973805269 | ||
|
|
56acb8d3b6 | ||
|
|
0ca6503f09 | ||
|
|
ca0673e0bc | ||
|
|
917dcdeeef | ||
|
|
25d348f391 | ||
|
|
403b335039 | ||
|
|
ddfd08f321 | ||
|
|
f0d3f81f3b | ||
|
|
3b683bbeec | ||
|
|
5527418234 | ||
|
|
38dd835af6 | ||
|
|
2269a58887 | ||
|
|
7bbb63e4bc | ||
|
|
6b06cba32d | ||
|
|
6af7769f08 | ||
|
|
8c21a7710d | ||
|
|
84e2da4bdd | ||
|
|
878d1f231a | ||
|
|
012395d49d | ||
|
|
ef32703362 | ||
|
|
156b492119 | ||
|
|
236e908dc9 | ||
|
|
6dc14c30a9 | ||
|
|
eb50f50599 | ||
|
|
700df5dbe4 | ||
|
|
7fee2c762f | ||
|
|
6cdd63638f | ||
|
|
2df4fede2a | ||
|
|
ee1203d463 | ||
|
|
98a988eaf4 | ||
|
|
e69b25faa6 | ||
|
|
c69463782a | ||
|
|
07e7d14f43 | ||
|
|
17736b6fe3 | ||
|
|
d12f75afcd | ||
|
|
5e01868020 | ||
|
|
6f129beb86 | ||
|
|
a282e4d2bf | ||
|
|
cafe3d658c | ||
|
|
9493e6f91d | ||
|
|
5cb8b1ba07 | ||
|
|
21b4e70150 | ||
|
|
6dfdf643d9 | ||
|
|
ad1e8d1112 | ||
|
|
f46b0f7dc4 | ||
|
|
cc3299dc0f | ||
|
|
467c47b719 | ||
|
|
03ffe790be | ||
|
|
ebc2857549 | ||
|
|
fb13a1e1f1 | ||
|
|
d049e69b95 | ||
|
|
288037543e | ||
|
|
f554c699a1 | ||
|
|
9336111655 | ||
|
|
bb66070841 | ||
|
|
f88242f576 | ||
|
|
b9069e761e | ||
|
|
88088e99bb | ||
|
|
54a8d60dae | ||
|
|
a3adaca19e | ||
|
|
837d2b5667 | ||
|
|
04806a98bf | ||
|
|
c91405dbea | ||
|
|
80b7aee756 | ||
|
|
d838af024b | ||
|
|
e510190867 | ||
|
|
ee6cd6de55 | ||
|
|
f14f8d8eb7 | ||
|
|
3b00334c41 | ||
|
|
e9edc49977 | ||
|
|
f9092edfe3 | ||
|
|
54e43d0a97 | ||
|
|
c5e38a309c | ||
|
|
842656f240 | ||
|
|
a64a5a9911 | ||
|
|
ae6dc17ffe | ||
|
|
2fa10c957c | ||
|
|
deff5b8f42 | ||
|
|
c334aa7e66 | ||
|
|
3f33e854d4 | ||
|
|
257217a2e3 | ||
|
|
f08f3619e3 | ||
|
|
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 | ||
|
|
b3ac169f00 | ||
|
|
dfb0ce36bd |
No files matched your search
@@ -1,19 +1,25 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -25,80 +31,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
|
||||
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
# The checkout above is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
run-package-check: true
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -19,48 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.vectorcraft" and m["command"] == "vectorcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
+23
-488
@@ -1,11 +1,5 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "VectorCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# Jobs that sign (macOS, Windows) and the job that creates the release run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# other jobs sign nothing and get no environment, so a `workflow_dispatch` on any branch is a dry
|
||||
# run: they build and upload artifacts, the signed jobs are refused, and no release is drafted.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -19,492 +13,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
VECTORCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets here.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
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)
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: Linux packages are not signed (checksums in SHA256SUMS.txt).
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/vectorcraft$|metainfo|applications'
|
||||
ldd target/release/vectorcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
# No APPIMAGE_EXTRACT_AND_RUN here: with it the runtime passes --appimage-* flags on
|
||||
# to the app instead of answering them itself.
|
||||
"$appimage" --appimage-updateinformation \
|
||||
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image and packages as
|
||||
# freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets here.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="VectorCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -1,22 +1,27 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# Windows ARM64 build and smoke test.
|
||||
#
|
||||
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
|
||||
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,72 +31,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
|
||||
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'vectorcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-dir: VectorCraft
|
||||
@@ -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 | |
|
||||
@@ -323,6 +325,11 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-6-revolve.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-7-dimensions.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/bracket-drawing.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-still-life.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `examples/still-life.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/bracket-drawing.vectorcraft` | VectorCraft contributors | Original drawing built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
|
||||
@@ -333,3 +340,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
+73
-50
@@ -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",
|
||||
@@ -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.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4525,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4536,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4548,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4564,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4586,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4598,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4615,8 +4632,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4630,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4666,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4687,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4702,7 +4720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4714,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4733,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4747,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4759,18 +4777,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.12.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",
|
||||
@@ -4784,11 +4802,12 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4802,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4818,23 +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.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4858,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4875,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4895,7 +4916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4903,7 +4924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4911,6 +4932,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
@@ -4918,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4930,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4959,11 +4981,12 @@ dependencies = [
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6073,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+21
-5
@@ -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.12.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" }
|
||||
@@ -58,10 +61,17 @@ 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"
|
||||
@@ -109,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.
|
||||
@@ -73,6 +73,16 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-6-revolve.png" alt="A still life of a vase, a bowl, a sphere and a bottle made with 3D Revolve; the vase is selected and the Revolve dialog shows its rotation, cap and view settings beside the rotation gizmo" width="100%">
|
||||
<p align="center"><sub><b>3D Revolve</b>: smooth vector shading, caps and a rotation gizmo · <code>examples/still-life.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-7-dimensions.png" alt="A bracket drawing in the light theme with linear, angular, diameter and radius dimensions; the Dimension tool is active and the Properties panel shows the selected dimension's units, precision and style" width="100%">
|
||||
<p align="center"><sub><b>Dimension tool</b>: lengths, angles and radii that follow the art they measure · <code>examples/bracket-drawing.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
|
||||
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
|
||||
@@ -108,6 +118,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/images/art-still-life.png" alt="Still life: a teal vase, a coral bowl, a white sphere and a mustard bottle on a warm table, all made with 3D Revolve" width="100%">
|
||||
<p align="center"><sub><b>Still Life</b>: four revolved profiles with smooth vector shading</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/art-repeat.png" alt="Radial Repeat mandala: teal petals around a coral center, ringed by yellow dots" width="100%">
|
||||
<p align="center"><sub><b>Radial Repeat</b>: a live mandala</sub></p>
|
||||
@@ -211,7 +227,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 +236,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.
|
||||
@@ -229,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
|
||||
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+27
-7
@@ -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-14 · **Change:** minor (merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **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
|
||||
@@ -26,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -35,7 +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) |
|
||||
@@ -77,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
@@ -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,8 +128,20 @@ 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 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 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. |
|
||||
@@ -162,6 +177,11 @@ 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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -95,6 +95,11 @@ USAGE:
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
--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
|
||||
@@ -109,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.
|
||||
@@ -223,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() {
|
||||
@@ -233,6 +242,9 @@ 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()),
|
||||
}
|
||||
}
|
||||
@@ -250,6 +262,9 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -115,3 +118,57 @@ fn convert_reports_import_warnings() {
|
||||
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"
|
||||
|
||||
@@ -29,6 +29,21 @@ 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,
|
||||
@@ -106,9 +121,10 @@ fn spawn_connection(
|
||||
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(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -455,6 +471,19 @@ mod tests {
|
||||
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\"}");
|
||||
|
||||
+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")]
|
||||
@@ -95,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() {
|
||||
@@ -480,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 => {
|
||||
@@ -500,6 +506,8 @@ 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))),
|
||||
@@ -531,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()
|
||||
@@ -614,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.
|
||||
@@ -639,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),
|
||||
}
|
||||
}
|
||||
@@ -722,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
|
||||
@@ -751,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.
|
||||
@@ -832,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());
|
||||
|
||||
+338
-14
@@ -1,29 +1,184 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
|
||||
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
|
||||
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
|
||||
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
|
||||
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
|
||||
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
|
||||
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
|
||||
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
|
||||
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
|
||||
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
|
||||
//! looks for the marker.
|
||||
//! 3. The platform convention.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
|
||||
/// The settings folder created in the install folder in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
|
||||
/// preferences are in use.
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
// A run without preferences reads and writes nothing, so it must not create the portable folder.
|
||||
if env("VECTORCRAFT_NO_PREFS").is_none()
|
||||
&& let Some(dir) = portable()
|
||||
{
|
||||
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
|
||||
return (!legacy).then(|| dir.join("ui.json"));
|
||||
}
|
||||
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
|
||||
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
|
||||
/// the settings path is asked for many times).
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let os = std::env::consts::OS;
|
||||
let env = |key: &str| std::env::var_os(key);
|
||||
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
|
||||
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
|
||||
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
|
||||
// keeps early records for the log file.
|
||||
log::warn!("{warning}");
|
||||
None
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
|
||||
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
|
||||
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
|
||||
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
|
||||
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent()?;
|
||||
let dir = match os {
|
||||
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
|
||||
"windows" => exe_dir.to_path_buf(),
|
||||
_ => {
|
||||
let appimage = env("APPIMAGE").map(PathBuf::from);
|
||||
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
|
||||
beside.unwrap_or(exe_dir).to_path_buf()
|
||||
}
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
|
||||
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
|
||||
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
|
||||
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
|
||||
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
|
||||
}
|
||||
|
||||
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
|
||||
/// `None` without a marker, and the warning to log when there is one but that folder can't be
|
||||
/// written. The first time the folder is created, the settings in `previous` (the user's profile
|
||||
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
|
||||
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
|
||||
/// is, and a later start finds the folder and copies nothing.
|
||||
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
|
||||
let data = install_dir.join(PORTABLE_DATA_DIR);
|
||||
let first_start = !data.exists();
|
||||
if let Err(e) = ensure_writable(&data) {
|
||||
return Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
));
|
||||
}
|
||||
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
|
||||
match copy_dir(previous, &data) {
|
||||
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
|
||||
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't give up portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
|
||||
/// copied; the error says how many were skipped and why the first one was.
|
||||
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let mut skipped = 0usize;
|
||||
let mut first: Option<String> = None;
|
||||
copy_dir_into(from, to, &mut |why| {
|
||||
skipped += 1;
|
||||
first.get_or_insert(why);
|
||||
});
|
||||
match first {
|
||||
None => Ok(()),
|
||||
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let entries = match std::fs::read_dir(from) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return skip(format!("{}: {e}", from.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
skip(format!("{}: {e}", from.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
// A symbolic link is copied as what it points to, or skipped when that is a folder.
|
||||
let copied = match entry.file_type() {
|
||||
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
|
||||
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = copied {
|
||||
skip(format!("{}: {e}", src.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
@@ -34,33 +189,202 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No portable marker in the install folder.
|
||||
fn installed() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env, installed), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
assert_eq!(prefs_path_with("macos", false, env, installed), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env, installed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
|
||||
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
|
||||
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
|
||||
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
|
||||
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
|
||||
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
|
||||
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
|
||||
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
|
||||
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
|
||||
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
|
||||
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
// The variable means nothing on other platforms.
|
||||
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp("portable");
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
|
||||
let folder = temp("bundle");
|
||||
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(folder.join("portable.txt"), b"").unwrap();
|
||||
let install = install_dir("macos", &exe, &environment).unwrap();
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
|
||||
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
|
||||
// A marker dropped inside the bundle is not looked at.
|
||||
std::fs::remove_file(folder.join("portable.txt")).unwrap();
|
||||
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
|
||||
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(None));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_marker_file_there_is_no_portable_folder() {
|
||||
let exe = temp("installed");
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
// A folder named like the marker is not a marker.
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
|
||||
let exe = temp("unwritable");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let warning = portable_dir_in(&exe, None).unwrap_err();
|
||||
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
|
||||
let exe = temp("upgrade");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let previous = temp("previous-profile");
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
|
||||
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
|
||||
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
|
||||
|
||||
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
|
||||
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
|
||||
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
|
||||
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
|
||||
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
|
||||
|
||||
// A second start finds the folder and copies nothing, whatever changed on either side.
|
||||
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
|
||||
std::fs::write(previous.join("later.txt"), b"").unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
|
||||
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
let _ = std::fs::remove_dir_all(&previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_user_profile_the_data_folder_starts_empty() {
|
||||
let exe = temp("fresh");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let missing = exe.join("no-such-profile");
|
||||
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
|
||||
let from = temp("copy-from");
|
||||
let to = temp("copy-to");
|
||||
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
|
||||
std::fs::create_dir_all(from.join("blocked")).unwrap();
|
||||
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
|
||||
// A file where the sub-folder should go: that entry can't be copied, the others can.
|
||||
std::fs::write(to.join("blocked"), b"in the way").unwrap();
|
||||
let error = copy_dir(&from, &to).unwrap_err();
|
||||
assert!(error.starts_with("1 skipped"), "{error}");
|
||||
assert!(error.contains("blocked"), "{error}");
|
||||
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
//! 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()>;
|
||||
@@ -30,8 +37,15 @@ mod platform {
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
strip(&hwnd);
|
||||
Some(Box::new(move || strip(&hwnd)))
|
||||
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
|
||||
@@ -65,6 +79,29 @@ mod platform {
|
||||
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")))]
|
||||
@@ -73,3 +110,65 @@ mod platform {
|
||||
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 |
@@ -98,3 +98,25 @@ sync_github_name = false
|
||||
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
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||||
let cases = [(true, true), (true, false), (false, true), (false, false)];
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, &(closed, returns)) in cases.iter().enumerate() {
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to((20.0, 20.0));
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
b.close_path();
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||||
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||||
}
|
||||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let text = std::str::from_utf8(&out.bytes).unwrap();
|
||||
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
|
||||
let back = leaves(&open(&out.bytes, &points()).document);
|
||||
assert_eq!(back.len(), cases.len(), "{version:?}");
|
||||
for (n, &(closed, returns)) in back.iter().zip(&cases) {
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||||
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_layers_layouts_and_version() {
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||||
|
||||
@@ -320,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);
|
||||
@@ -506,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
|
||||
|
||||
+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(_))));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+160
-3
@@ -145,7 +145,7 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::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(),
|
||||
};
|
||||
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
|
||||
tops
|
||||
}
|
||||
|
||||
/// The most objects [`locked_objects_at`] lists.
|
||||
pub const MAX_LOCKED_AT: usize = 20;
|
||||
|
||||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = Vec::new();
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||||
out.push(isolated.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
locked_children(layer, p, opt, &symbols, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
if out.len() >= MAX_LOCKED_AT {
|
||||
return;
|
||||
}
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.locked {
|
||||
if locked_hit(c, p, opt, symbols) {
|
||||
out.push(c.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||||
NodeKind::Layer { .. }
|
||||
| NodeKind::Group { .. }
|
||||
| NodeKind::Intertwine { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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> {
|
||||
@@ -262,7 +336,8 @@ fn hit_children(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
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;
|
||||
}
|
||||
@@ -270,7 +345,9 @@ fn hit_children(
|
||||
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 }, symbols, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, 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, symbols, skip, chain, editable_only),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
@@ -287,6 +364,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.
|
||||
@@ -881,6 +975,69 @@ world",
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Insert `n` locked at the top of `parent`.
|
||||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||||
let id = n.id;
|
||||
n.locked = true;
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||||
#[test]
|
||||
fn locked_objects_at_lists_the_locked_stack() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let rect =
|
||||
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||||
inner.locked = true;
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||||
g.locked = true;
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
let open_group = d.alloc_id();
|
||||
let mut child = rect(&mut d, 25.0);
|
||||
child.locked = true;
|
||||
let child_id = child.id;
|
||||
let free = rect(&mut d, 25.0);
|
||||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||||
assert_eq!(at(&d, 5.0, None), [a]);
|
||||
assert!(at(&d, 150.0, None).is_empty());
|
||||
// Isolated, only the group's own objects are looked at.
|
||||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||||
d.node_mut(l).unwrap().locked = true;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
d.node_mut(l).unwrap().locked = false;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||||
#[test]
|
||||
fn locked_objects_at_stops_at_its_limit() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||||
.map(|_| {
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||||
insert_locked(&mut d, l, n)
|
||||
})
|
||||
.collect();
|
||||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+231
-20
@@ -11,14 +11,21 @@ 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_export_type;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
@@ -30,6 +37,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;
|
||||
@@ -79,6 +87,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,
|
||||
@@ -103,9 +112,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -116,6 +125,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}")]
|
||||
@@ -229,7 +241,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())
|
||||
}
|
||||
@@ -266,20 +281,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)
|
||||
@@ -299,7 +335,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))
|
||||
@@ -326,6 +365,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> {
|
||||
@@ -683,6 +730,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,
|
||||
@@ -703,6 +754,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)
|
||||
@@ -763,6 +821,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -773,7 +832,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 {
|
||||
@@ -781,14 +842,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.
|
||||
@@ -1374,7 +1443,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)
|
||||
@@ -1386,6 +1455,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();
|
||||
@@ -1458,7 +1534,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 {
|
||||
@@ -1480,6 +1556,76 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored registration when a symbol is renamed.
|
||||
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let reg = self.symbol_registration(from);
|
||||
self.set_symbol_registration(from, SymbolRegistration::CENTER);
|
||||
self.set_symbol_registration(to, reg);
|
||||
}
|
||||
|
||||
/// Symbol Options › Export Type for `name` (default: Movie Clip).
|
||||
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
|
||||
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
|
||||
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
|
||||
if kind.is_default() {
|
||||
self.clear_symbol_export_type(name);
|
||||
return;
|
||||
}
|
||||
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.insert(name.into(), serde_json::json!(kind.id()));
|
||||
}
|
||||
|
||||
/// Drop any stored export type for `name` (Movie Clip default).
|
||||
pub fn clear_symbol_export_type(&mut self, name: &str) {
|
||||
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_EXPORT_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored export type when a symbol is renamed.
|
||||
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let kind = self.symbol_export_type(from);
|
||||
self.clear_symbol_export_type(from);
|
||||
self.set_symbol_export_type(to, kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1497,11 +1643,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,6 +1790,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();
|
||||
@@ -1698,6 +1901,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)]
|
||||
|
||||
+11
-2
@@ -1437,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);
|
||||
}
|
||||
@@ -1616,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.
|
||||
@@ -1651,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()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+120
-5
@@ -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};
|
||||
@@ -486,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 {
|
||||
@@ -543,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
|
||||
@@ -555,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.
|
||||
@@ -564,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
|
||||
@@ -692,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(),
|
||||
@@ -699,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();
|
||||
@@ -726,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);
|
||||
@@ -733,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()
|
||||
}
|
||||
}
|
||||
@@ -772,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)]
|
||||
@@ -784,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 {
|
||||
@@ -794,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,
|
||||
@@ -843,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 {
|
||||
@@ -884,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,
|
||||
}
|
||||
}
|
||||
@@ -895,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,
|
||||
}
|
||||
}
|
||||
@@ -963,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>`.
|
||||
@@ -1074,6 +1142,8 @@ impl Node {
|
||||
// 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
|
||||
@@ -1108,6 +1178,7 @@ impl Node {
|
||||
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();
|
||||
@@ -1146,6 +1217,13 @@ impl Node {
|
||||
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))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1159,6 +1237,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);
|
||||
}
|
||||
@@ -1197,7 +1281,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);
|
||||
}
|
||||
@@ -1251,6 +1338,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)) {
|
||||
@@ -1379,7 +1470,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);
|
||||
}
|
||||
}
|
||||
@@ -1794,6 +1885,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 {
|
||||
@@ -1923,6 +2017,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());
|
||||
@@ -1961,6 +2056,7 @@ mod tests {
|
||||
"mask",
|
||||
"trace",
|
||||
"series_index",
|
||||
"type_outlines",
|
||||
"kind",
|
||||
"graphic_style",
|
||||
"attrs",
|
||||
@@ -2247,6 +2343,25 @@ mod tests {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
fn type_visual_bounds_grow_with_the_character_stroke() {
|
||||
use crate::appearance::StrokeAlign;
|
||||
use crate::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
|
||||
t.xf = Affine::scale(2.0) * t.xf;
|
||||
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
let g = node(&t).geometric_bounds().unwrap();
|
||||
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
|
||||
t.runs[0].style.stroke_align = align;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
|
||||
}
|
||||
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -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,49 @@
|
||||
//! Symbol Options › Export Type: Graphic or Movie Clip
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
|
||||
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
|
||||
//! by symbol name; the default (Movie Clip) is omitted from the map.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
|
||||
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
|
||||
|
||||
/// Symbol Options › Export Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SymbolExportType {
|
||||
/// Static / graphic symbol instance (Animate Graphic).
|
||||
Graphic,
|
||||
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
|
||||
#[default]
|
||||
MovieClip,
|
||||
}
|
||||
|
||||
impl SymbolExportType {
|
||||
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Graphic => "graphic",
|
||||
Self::MovieClip => "movieClip",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"graphic" => Some(Self::Graphic),
|
||||
"movieClip" => Some(Self::MovieClip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the document default (nothing stored for the symbol).
|
||||
pub fn is_default(self) -> bool {
|
||||
matches!(self, Self::MovieClip)
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
v.as_str().and_then(Self::from_id)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
+79
-5
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
@@ -84,6 +84,10 @@ pub struct CharStyle {
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
|
||||
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Superscript or subscript (Character panel).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub position: CharPosition,
|
||||
@@ -249,6 +253,7 @@ impl Default for CharStyle {
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
@@ -266,8 +271,9 @@ impl CharStyle {
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
}
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
|
||||
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
|
||||
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
|
||||
/// and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
@@ -275,11 +281,13 @@ impl CharStyle {
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
align: self.stroke_align,
|
||||
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
|
||||
}
|
||||
}
|
||||
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
|
||||
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
|
||||
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
|
||||
/// character stroke (the options characters have no use for, such as arrowheads and width
|
||||
/// profiles, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
@@ -287,6 +295,19 @@ impl CharStyle {
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
self.stroke_align = st.align;
|
||||
}
|
||||
/// How far the character stroke paints past the glyph outlines (text space): half the weight
|
||||
/// centred, the whole weight outside, nothing inside (miter spikes aside).
|
||||
pub fn stroke_outset(&self) -> f64 {
|
||||
if !self.has_stroke() {
|
||||
return 0.0;
|
||||
}
|
||||
match self.stroke_align {
|
||||
StrokeAlign::Center => self.stroke_width / 2.0,
|
||||
StrokeAlign::Outside => self.stroke_width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
@@ -360,6 +381,37 @@ impl Composer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
|
||||
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
|
||||
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
|
||||
/// limit, no hyphenation zone, and capitalized words hyphenated.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HyphenationOptions {
|
||||
/// Words Longer Than: only words with more letters than this are hyphenated.
|
||||
pub words_longer_than: u32,
|
||||
/// After First: the fewest letters a word keeps before the hyphen.
|
||||
pub after_first: u32,
|
||||
/// Before Last: the fewest letters a word keeps after the hyphen.
|
||||
pub before_last: u32,
|
||||
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
|
||||
/// 0 sets no limit.
|
||||
pub hyphen_limit: u32,
|
||||
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
|
||||
/// word already ends within this distance of its right edge breaks before that word instead
|
||||
/// of hyphenating it. 0 allows every hyphenation.
|
||||
pub zone: f64,
|
||||
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
|
||||
/// hyphenated. Words in capitals only are never hyphenated.
|
||||
pub capitalized: bool,
|
||||
}
|
||||
|
||||
impl Default for HyphenationOptions {
|
||||
fn default() -> Self {
|
||||
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -374,6 +426,8 @@ pub struct ParaStyle {
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -421,6 +475,8 @@ struct ParaStyleFile {
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -734,7 +794,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 +827,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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
+534
-10
@@ -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_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -98,8 +106,13 @@ 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.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -107,9 +120,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 +173,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 +568,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 +618,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");
|
||||
}
|
||||
}
|
||||
+65
-15
@@ -6,7 +6,7 @@
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -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"]),
|
||||
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const SKETCH: &[&str] = &["Effect", "Sketch"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
@@ -200,8 +202,8 @@ 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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
"{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,
|
||||
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
|
||||
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
|
||||
),
|
||||
g(
|
||||
"path.outlineObject",
|
||||
"Outline Object",
|
||||
PATH,
|
||||
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
|
||||
json!({}),
|
||||
),
|
||||
g(
|
||||
"path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
@@ -301,15 +310,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 +354,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 +424,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(
|
||||
@@ -432,6 +441,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -642,8 +658,9 @@ pub fn default_params(id: &str) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
|
||||
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
|
||||
/// `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(),
|
||||
@@ -660,14 +677,17 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
|
||||
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
|
||||
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
|
||||
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
|
||||
/// (`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(),
|
||||
@@ -700,6 +720,21 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -749,6 +784,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> {
|
||||
@@ -834,6 +881,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
}
|
||||
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
|
||||
}
|
||||
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
|
||||
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
|
||||
"path.outlineObject" => path.clone(),
|
||||
"path.outlineStroke" => {
|
||||
let fallback;
|
||||
let base = match ctx.stroke {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
@@ -17,8 +18,10 @@ mod blur;
|
||||
mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod loaded;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -27,13 +30,16 @@ 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 sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 29] = [
|
||||
"artistic.filmGrain",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
@@ -53,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.halftonePattern",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -66,7 +73,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 +84,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 +117,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.
|
||||
@@ -117,6 +127,9 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -211,6 +224,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 +272,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),
|
||||
},
|
||||
@@ -270,6 +284,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -346,7 +365,7 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
@@ -389,7 +408,7 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -412,7 +431,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,11 +446,12 @@ 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),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
@@ -448,8 +473,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).
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
|
||||
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HalftonePattern {
|
||||
/// Rings round the object's centre.
|
||||
Circle,
|
||||
/// Dots on a square grid.
|
||||
Dot,
|
||||
/// Horizontal lines.
|
||||
Line,
|
||||
}
|
||||
|
||||
impl HalftonePattern {
|
||||
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
|
||||
|
||||
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
|
||||
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
|
||||
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
|
||||
/// and white at 50. Coverage is kept.
|
||||
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
|
||||
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
|
||||
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
|
||||
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
|
||||
let screen = match kind {
|
||||
HalftonePattern::Line => band(q.y),
|
||||
HalftonePattern::Circle => band(q.x.hypot(q.y)),
|
||||
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
|
||||
};
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let ink = if dark >= 1.0 {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
|
||||
let threshold = 0.5 * soft + screen * (1.0 - soft);
|
||||
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
|
||||
} else if dark > screen {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1721,3 +1893,119 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest period of `values` (their length when they don't repeat).
|
||||
fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
|
||||
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
|
||||
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
|
||||
);
|
||||
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
|
||||
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
|
||||
}
|
||||
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
|
||||
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
|
||||
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
|
||||
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.halftonePattern", json!({"size": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
|
||||
let (w, h) = (48, 48);
|
||||
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
|
||||
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
|
||||
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
|
||||
assert_eq!(period(&column), 4, "{column:?}");
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
|
||||
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
|
||||
assert_eq!(period(&column), 8, "{column:?}");
|
||||
}
|
||||
|
||||
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
|
||||
/// Dot neither.
|
||||
#[test]
|
||||
fn halftone_pattern_type_changes_the_pattern() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(100));
|
||||
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
|
||||
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
|
||||
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
|
||||
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
|
||||
assert!(circle != dot && dot != line && line != circle);
|
||||
// Pixels at the same distance from the centre in different directions.
|
||||
let same_radius: [&[(i64, i64)]; 6] = [
|
||||
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
|
||||
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
|
||||
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
|
||||
&[(15, 0), (9, 12), (-12, 9)],
|
||||
&[(17, 0), (8, 15), (-15, -8)],
|
||||
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
|
||||
];
|
||||
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
|
||||
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
|
||||
assert!(radial(&circle), "Circle is the same all round");
|
||||
assert!(!radial(&dot) && !radial(&line));
|
||||
for y in 0..h {
|
||||
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
|
||||
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
|
||||
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
|
||||
assert_eq!(mid(&hard), 0.0);
|
||||
}
|
||||
|
||||
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
|
||||
/// keeps its coverage.
|
||||
#[test]
|
||||
fn halftone_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
for kind in ["circle", "dot", "line"] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "{kind}: coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
|
||||
for (tone, expect) in [(0, 0), (255, 255)] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
+237
-23
@@ -5,12 +5,14 @@
|
||||
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
|
||||
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
|
||||
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 +32,25 @@ 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.
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
|
||||
/// 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 +59,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 +109,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 +130,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,24 +141,37 @@ 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. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
@@ -154,11 +211,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 +256,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 +273,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 +430,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};
|
||||
|
||||
@@ -38,6 +38,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,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(|_| ())
|
||||
}
|
||||
|
||||
@@ -95,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()
|
||||
@@ -104,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.
|
||||
@@ -121,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)]
|
||||
})
|
||||
@@ -134,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),
|
||||
@@ -224,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();
|
||||
|
||||
@@ -113,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")
|
||||
@@ -134,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
|
||||
@@ -185,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,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);
|
||||
@@ -213,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))])
|
||||
}
|
||||
|
||||
|
||||
@@ -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>> {
|
||||
@@ -112,14 +145,19 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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());
|
||||
@@ -140,6 +178,8 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
// 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,
|
||||
}
|
||||
@@ -160,10 +200,12 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
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)));
|
||||
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
|
||||
@@ -176,9 +218,10 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
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(), end, row }),
|
||||
None => chains.push(Chain { pts: pair.to_vec(), bends: bend.to_vec(), end, row }),
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
@@ -187,7 +230,15 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let lines: Vec<SubPath> = chains
|
||||
.into_iter()
|
||||
.filter(|c| c.pts.len() >= 2)
|
||||
.map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) })
|
||||
.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();
|
||||
@@ -198,3 +249,17 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
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
|
||||
}
|
||||
+354
-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());
|
||||
@@ -360,6 +572,18 @@ fn outline_stroke_uses_context_stroke() {
|
||||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
let info = effect_info("path.outlineObject").unwrap();
|
||||
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
|
||||
assert!(is_geometry("path.outlineObject"));
|
||||
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
|
||||
// The effects after it reshape the same geometry.
|
||||
let b = square().bounds().unwrap();
|
||||
let offset = fx("path.offsetPath", json!({"offset": 5}));
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
@@ -382,6 +606,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());
|
||||
@@ -418,6 +702,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}))];
|
||||
@@ -444,11 +781,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:?}"),
|
||||
}
|
||||
@@ -457,6 +795,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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
pub(crate) 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, SymbolExportType, 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,22 +95,31 @@ 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, exportType: movieClip|graphic}], 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), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
cmd!(
|
||||
"symbol.options",
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
cmd!(
|
||||
"symbol.place",
|
||||
"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 +207,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 +520,39 @@ 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<Option<SymbolRegistration>> {
|
||||
match p.get("registration") {
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
match str_param(p, "exportType") {
|
||||
None => Ok(None),
|
||||
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
|
||||
set_natural_size(d, from, None);
|
||||
if let Some(v) = size
|
||||
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
|
||||
{
|
||||
set_natural_size(d, to, Some((w, h)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +590,13 @@ 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,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
|
||||
@@ -576,20 +619,78 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
|
||||
}
|
||||
|
||||
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.new";
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = 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, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
// 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.set_symbol_export_type(&name, export);
|
||||
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, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.options";
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() {
|
||||
return Err(bad(C, "pass newName, registration and/or exportType"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
|
||||
{
|
||||
return Err(bad(C, format!("a symbol named `{n}` already exists")));
|
||||
}
|
||||
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
|
||||
s.edit("Symbol Options", |d, _| {
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
{
|
||||
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
|
||||
sym.name = n.clone();
|
||||
}
|
||||
for id in instances_of(d, Some(&name)) {
|
||||
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
|
||||
*symbol = n.clone();
|
||||
}
|
||||
}
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
}
|
||||
if let Some(r) = reg {
|
||||
d.set_symbol_registration(&final_name, r);
|
||||
}
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
|
||||
@@ -606,12 +707,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 +822,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 +846,8 @@ 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);
|
||||
d.clear_symbol_export_type(&name);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -758,13 +863,17 @@ 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);
|
||||
let export = d.symbol_export_type(&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);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -882,13 +991,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) => {
|
||||
|
||||
@@ -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, 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}",
|
||||
"{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")) };
|
||||
@@ -738,8 +741,9 @@ fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Pain
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
/// 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];
|
||||
@@ -761,8 +765,14 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
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));
|
||||
}
|
||||
@@ -778,12 +788,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
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) = {
|
||||
@@ -848,10 +857,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
// 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)
|
||||
})],
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
@@ -320,6 +338,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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())))
|
||||
})?;
|
||||
@@ -683,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()],
|
||||
@@ -823,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),
|
||||
}
|
||||
}
|
||||
@@ -913,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,
|
||||
@@ -928,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) {
|
||||
@@ -1116,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");
|
||||
|
||||
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.update",
|
||||
"Update Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
|
||||
has_recalled_selection,
|
||||
update_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+5"),
|
||||
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
|
||||
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
|
||||
has_doc,
|
||||
release_guides
|
||||
),
|
||||
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
let mut slots = saved_ids(s)?;
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if existing.is_none() {
|
||||
slots.push(st.recalled_selection.new_id());
|
||||
}
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let saved = &s.doc()?.doc.saved_selections;
|
||||
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
|
||||
let st = s.doc()?;
|
||||
let can = selectable_ids(&st.doc, st.isolation);
|
||||
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.recall(&st.doc.saved_selections, i);
|
||||
Ok(json!({ "count": st.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
|
||||
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
|
||||
let st = s.doc_mut()?;
|
||||
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
|
||||
}
|
||||
|
||||
/// Where the saved selection last recalled is, while the document has it.
|
||||
fn recalled_index(st: &crate::DocState) -> Option<usize> {
|
||||
st.recalled_selection.index_in(&st.doc.saved_selections)
|
||||
}
|
||||
|
||||
/// Update Selection needs selected objects and a recalled saved selection the document still has.
|
||||
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
|
||||
}
|
||||
|
||||
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "select.update";
|
||||
let st = s.doc()?;
|
||||
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let slots = saved_ids(s)?;
|
||||
s.edit("Update Selection", |d, _| {
|
||||
if let Some(x) = d.saved_selections.get_mut(i) {
|
||||
x.objects = ids;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.saved_selections = after.into();
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
|
||||
/// guide paths and ruler guides, else every guide path, in one undo step.
|
||||
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.guides.release";
|
||||
let (index, ids) = (p.get("index"), p.get("ids"));
|
||||
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let rulers = match index {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
None => vec![],
|
||||
};
|
||||
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
let mut paths = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(r) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
|
||||
paths.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
(rulers, paths)
|
||||
} else {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let rulers: Vec<usize> = if s.menu.guides_locked {
|
||||
vec![]
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
};
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
|
||||
};
|
||||
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
|
||||
if targets.is_empty() {
|
||||
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
|
||||
let mut lines = Vec::with_capacity(rulers.len());
|
||||
for &i in &rulers {
|
||||
let Some(g) = st.doc.guides.get(i) else { continue };
|
||||
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some((a, b)) = span else { continue };
|
||||
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
|
||||
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
|
||||
Some(l) => Some(l),
|
||||
None => st.target_parent()?,
|
||||
};
|
||||
lines.push((i, parent, a, b));
|
||||
}
|
||||
let n = paths.len() + lines.len();
|
||||
if n == 0 {
|
||||
return Err(EngineError::Other("Release Guides: no guides".into()));
|
||||
}
|
||||
let n = targets.len();
|
||||
s.edit("Release Guides", |d, sel| {
|
||||
for id in &targets {
|
||||
for id in &paths {
|
||||
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*guide = false;
|
||||
}
|
||||
}
|
||||
sel.set(targets.iter().copied());
|
||||
let mut made = Vec::with_capacity(lines.len());
|
||||
for &(_, parent, a, b) in &lines {
|
||||
let id = d.alloc_id();
|
||||
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
|
||||
made.push(id);
|
||||
}
|
||||
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
|
||||
sel.set(paths.iter().copied().chain(made));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
@@ -659,6 +771,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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
@@ -12,7 +13,8 @@ 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, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -24,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, 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 +44,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
|
||||
),
|
||||
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
@@ -69,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)",
|
||||
"{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
|
||||
),
|
||||
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
@@ -544,6 +546,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 +608,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 }));
|
||||
@@ -622,7 +629,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 ----------
|
||||
@@ -643,7 +650,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);
|
||||
@@ -688,6 +695,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),
|
||||
@@ -712,7 +720,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 => {
|
||||
@@ -766,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
let shape: Vec<(usize, bool)> =
|
||||
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
|
||||
let next = following_anchors(&shape, &anchors);
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
|
||||
removed += 1;
|
||||
} else if !next.is_empty() {
|
||||
// The next anchor along is selected, so the next Remove Anchor Points goes on from
|
||||
// there without picking it first (#2241).
|
||||
sel.anchors.insert(id, next);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
@@ -774,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
|
||||
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
|
||||
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
|
||||
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
|
||||
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
|
||||
let mut next = BTreeSet::new();
|
||||
let mut kept = 0;
|
||||
for (si, &(n, closed)) in shape.iter().enumerate() {
|
||||
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
|
||||
if n - gone.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(&last) = gone.last() {
|
||||
let wrap = if closed { 0..last } else { 0..0 };
|
||||
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
|
||||
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
|
||||
next.insert((kept, a - gone.range(..a).count()));
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
@@ -819,14 +860,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(())
|
||||
})?;
|
||||
@@ -1301,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) {
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
continue;
|
||||
@@ -1316,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ap = Appearance::basic(paint, Paint::None, 1.0);
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
return add_node(s, "Blob Brush", path_kind(region), ap, None);
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
}
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::unite_all(&inputs);
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
if keep_selected {
|
||||
sel.set([keep]);
|
||||
} else {
|
||||
sel.remove(keep);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": keep.0 }))
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
None,
|
||||
"{dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
"{ids?: [id…] (selected first; default: the selection), dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
@@ -259,16 +259,23 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
|
||||
// the document.
|
||||
/// What is still in progress (typing, a drag) is a step of its own: keep it before the history
|
||||
/// moves, so Undo takes back only that and Redo brings it back, instead of dropping it and undoing
|
||||
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
|
||||
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
|
||||
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
|
||||
/// document.
|
||||
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -286,6 +293,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -316,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -328,23 +349,37 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
sel.dedup_by_key(|(id, _)| *id);
|
||||
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
|
||||
sel.iter()
|
||||
.filter(|(_, path)| {
|
||||
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
|
||||
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
|
||||
})
|
||||
without_inner(doc, sel)
|
||||
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
|
||||
.map(|(id, _)| *id)
|
||||
.map(|(id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ids` in the order given, less those whose ancestor is also listed: an object listed with a
|
||||
/// group that holds it is dropped, as [`roots_of`] drops it from a selection. Nothing else is
|
||||
/// dropped or replaced: unlike `roots_of`, it keeps layers, ids that aren't in the document and
|
||||
/// compound-path members.
|
||||
pub(crate) fn outermost(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
let placed = ids.into_iter().map(|id| (id, paths.get(&id).cloned())).collect();
|
||||
without_inner(doc, placed).map(|(id, _)| id).collect()
|
||||
}
|
||||
|
||||
/// The entries of `placed` (ids with their index paths) whose id has no ancestor among its ids.
|
||||
fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> impl Iterator<Item = (NodeId, Option<Vec<usize>>)> + '_ {
|
||||
let listed: std::collections::HashSet<NodeId> = placed.iter().map(|(id, _)| *id).collect();
|
||||
placed.into_iter().filter(move |(_, path)| {
|
||||
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
|
||||
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
|
||||
})
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// The Artboard tool copies its artboard (and the art on it).
|
||||
if s.tool_id() == "artboard" {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -142,6 +142,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
}
|
||||
if id == GLASS {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
@@ -297,16 +300,30 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
let warp_id = p
|
||||
.get("warpStyle")
|
||||
.map(|v| -> Result<String> {
|
||||
let style = v.as_str().filter(|style| effects::WarpStyle::from_id(style).is_some()).ok_or_else(|| bad(C, "unknown Warp style"))?;
|
||||
Ok(format!("warp.{style}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
}
|
||||
if let Some(e) = effect
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
@@ -323,6 +340,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let Some(id) = &warp_id {
|
||||
e.id.clone_from(id);
|
||||
}
|
||||
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) {
|
||||
if !cur.is_object() {
|
||||
*cur = effects::merged_params(&e.id, &Value::Null);
|
||||
@@ -342,6 +362,31 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
// ---------- Load Texture ----------
|
||||
|
||||
/// Effect › Distort › Glass.
|
||||
const GLASS: &str = "distort.glass";
|
||||
|
||||
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
|
||||
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
|
||||
/// `params` give a `texture`; a string must be a texture as Glass stores it.
|
||||
fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
let pick = params.get("texture").is_none();
|
||||
let Some(v) = params.get_mut(effects::pixel::TEXTURE_IMAGE) else { return Ok(()) };
|
||||
match v {
|
||||
Value::String(s) if effects::pixel::texture(s).is_some() => {}
|
||||
Value::Object(_) => {
|
||||
let bytes = fileio::source(v, cmd)?.bytes;
|
||||
*v = json!(fileio::texture_image(&bytes).map_err(|e| bad(cmd, format!("textureImage: {e}")))?);
|
||||
if pick {
|
||||
params["texture"] = json!(effects::pixel::LOADED);
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Expand Appearance ----------
|
||||
|
||||
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
|
||||
@@ -407,7 +452,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
|
||||
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(mut n) = d.node(id).cloned() else { return };
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
@@ -647,10 +692,98 @@ mod tests {
|
||||
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
|
||||
}
|
||||
|
||||
/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the
|
||||
/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°.
|
||||
#[test]
|
||||
fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}});
|
||||
s.execute("effect.apply", &p).unwrap();
|
||||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||||
assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s));
|
||||
assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2)));
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_eq!(params(&s)["angle"].as_f64(), Some(160.0));
|
||||
// A file of then stores its variation; setting one of the two keeps it for the other.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3});
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1}));
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_revolve_commands_validate_save_expand_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap();
|
||||
let initial = node(&s, id);
|
||||
let source = initial.path_data().unwrap().clone();
|
||||
assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap());
|
||||
let mut gradients = 0;
|
||||
effects_for_test(&s, id).walk(&mut |face| {
|
||||
gradients += face.appearance.items.iter().filter(|item| {
|
||||
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
|
||||
}).count();
|
||||
});
|
||||
assert!(gradients > 0, "commands use smooth vector shading by default");
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap();
|
||||
let smooth = node(&s, id);
|
||||
assert_eq!(smooth.path_data(), Some(&source));
|
||||
assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap());
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id)));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), smooth);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
|
||||
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
|
||||
)
|
||||
.unwrap();
|
||||
let uncapped = node(&s, id);
|
||||
assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false));
|
||||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
let open_count = count(&effects_for_test(&s, id));
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap();
|
||||
let capped = node(&s, id);
|
||||
let art = effects_for_test(&s, id);
|
||||
assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap");
|
||||
assert_eq!(capped.path_data(), Some(&source));
|
||||
assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err());
|
||||
assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document");
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true));
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone()));
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF"));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert_eq!(count(&node(&s, id)), count(&art));
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), capped);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), uncapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||||
let mut s = Session::new();
|
||||
@@ -695,6 +828,56 @@ mod tests {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_style_change_preserves_slot_and_visibility() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||||
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
|
||||
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
|
||||
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
|
||||
let n = node(&s, id);
|
||||
let new = n.appearance.effects_at(Some(1)).unwrap();
|
||||
assert_eq!(new.len(), 3);
|
||||
assert_eq!(new[0], old[0]);
|
||||
assert_eq!(new[2], old[2]);
|
||||
assert_eq!(new[1].id, "warp.flag");
|
||||
assert_eq!(new[1].params["bend"], 25.0);
|
||||
assert!(!new[1].visible);
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_non_warp_target_rejection_is_atomic() {
|
||||
let (mut s, first) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
|
||||
let state = s.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo);
|
||||
assert!(!state.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_remove_and_set_params() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
@@ -770,6 +953,29 @@ mod tests {
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
let glyphs = effects::outline_text(&node(&s, t)).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}");
|
||||
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
|
||||
let glyph_area = area(&glyphs);
|
||||
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
|
||||
// Live: the renderer leaves the glyph's place in the square empty.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count();
|
||||
assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}");
|
||||
// Expanded: the group holds the square minus the glyph.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let left = area(&node(&s, g));
|
||||
assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
|
||||
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it.
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
|
||||
//! it gives ([`read_pdf`]).
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
|
||||
@@ -62,6 +63,30 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(info_json(&s.doc()?.doc))
|
||||
}
|
||||
|
||||
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
|
||||
/// field that `file.info` would take (a value it refuses is left out), and the created and
|
||||
/// modified dates.
|
||||
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
|
||||
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
|
||||
let fields = [
|
||||
("author", f.author.map(Value::from)),
|
||||
("description", f.description.map(Value::from)),
|
||||
("keywords", f.keywords.map(Value::from)),
|
||||
("copyrightStatus", status.map(|s| Value::from(s.id()))),
|
||||
("copyrightNotice", f.copyright_notice.map(Value::from)),
|
||||
("copyrightUrl", f.copyright_url.map(Value::from)),
|
||||
];
|
||||
// `apply` changes the field only when it takes the value.
|
||||
let mut title = String::new();
|
||||
for (k, v) in fields {
|
||||
if let Some(v) = v {
|
||||
let _ = apply(&mut title, m, k, &v);
|
||||
}
|
||||
}
|
||||
m.created = f.created.or(m.created);
|
||||
m.modified = f.modified.or(m.modified);
|
||||
}
|
||||
|
||||
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
|
||||
match k {
|
||||
"title" => {
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! `action.batch`: play an action's commands on every file in a folder, or on every data set
|
||||
//! of the open document (the Actions panel menu's Batch…).
|
||||
//!
|
||||
//! Public options follow the Batch dialog: source Folder or Data Sets; destination None,
|
||||
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("action.batch", "no file system here"))
|
||||
}
|
||||
|
||||
/// Caps on a batch (folders and MCP payloads are untrusted).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BATCH_FILES: usize = 500;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BATCH_DEPTH: usize = 8;
|
||||
|
||||
/// Shared Batch options for one run.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Run<'a> {
|
||||
label: &'a str,
|
||||
steps: &'a [Value],
|
||||
stop_on_error: bool,
|
||||
override_save: bool,
|
||||
override_export: bool,
|
||||
destination: &'a str,
|
||||
dest_folder: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Play `commands` on each source file or data set.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use super::{absolute_path, may_write};
|
||||
|
||||
const C: &str = "action.batch";
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad(C, "missing `commands`"))?;
|
||||
if cmds.is_empty() {
|
||||
return Err(bad(C, "`commands` must not be empty"));
|
||||
}
|
||||
if cmds.len() > 256 {
|
||||
return Err(bad(C, "more than 256 commands"));
|
||||
}
|
||||
for (i, c) in cmds.iter().enumerate() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id.is_empty() {
|
||||
return Err(bad(C, format!("commands[{i}]: missing `command`")));
|
||||
}
|
||||
if id == "command.batch" || id == "action.batch" {
|
||||
return Err(bad(C, "batches cannot nest"));
|
||||
}
|
||||
if find_command(id).is_none() {
|
||||
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
|
||||
}
|
||||
}
|
||||
let source = str_param(p, "source").unwrap_or("folder");
|
||||
let destination = str_param(p, "destination").unwrap_or("none");
|
||||
if !matches!(destination, "none" | "saveAndClose" | "folder") {
|
||||
return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`"));
|
||||
}
|
||||
let dest_folder = str_param(p, "destinationFolder").map(absolute_path);
|
||||
if destination == "folder" {
|
||||
let Some(ref folder) = dest_folder else {
|
||||
return Err(bad(C, "`destinationFolder` is required when destination is `folder`"));
|
||||
};
|
||||
may_write(folder)?;
|
||||
std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?;
|
||||
}
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
let override_open = bool_or(p, "overrideOpen", true);
|
||||
let run = Run {
|
||||
label: &label,
|
||||
steps: cmds,
|
||||
stop_on_error: bool_or(p, "stopOnError", true),
|
||||
override_save: bool_or(p, "overrideSave", true),
|
||||
override_export: bool_or(p, "overrideExport", true),
|
||||
destination,
|
||||
dest_folder: dest_folder.as_deref(),
|
||||
};
|
||||
match source {
|
||||
"folder" => batch_folder(s, C, p, override_open, &run),
|
||||
"datasets" => batch_datasets(s, C, &run),
|
||||
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
|
||||
use super::{absolute_path, may_read};
|
||||
|
||||
let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?;
|
||||
may_read(&folder)?;
|
||||
let include_subdirs = bool_or(p, "includeSubdirs", false);
|
||||
let files = collect_files(&folder, include_subdirs, 0)?;
|
||||
if files.len() > MAX_BATCH_FILES {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files")));
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Err(bad(cmd, "no readable files in the folder"));
|
||||
}
|
||||
let steps = filter_steps(run.steps, override_open, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for path in &files {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = super::extension(path);
|
||||
match process_one_file(s, &run, path, stem, &ext) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
processed.push(json!({"path": path, "error": e.to_string()}));
|
||||
if run.stop_on_error {
|
||||
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, "no data sets"));
|
||||
}
|
||||
if names.len() > MAX_BATCH_FILES {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
processed.push(json!({"dataset": name, "error": e.to_string()}));
|
||||
if run.stop_on_error {
|
||||
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
||||
}
|
||||
|
||||
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
|
||||
cmds.iter()
|
||||
.filter(|c| {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if drop_open && matches!(id, "document.open" | "file.open") {
|
||||
return false;
|
||||
}
|
||||
if drop_close && matches!(id, "file.close" | "document.close") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
|
||||
s.execute("document.open", &json!({"path": path}))?;
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Folder sources open a file per pass: close after Save and Close / Folder.
|
||||
finish_destination(s, run, stem, ext, path, true)?;
|
||||
Ok(json!({"path": path, "result": batch}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, source_path: &str, close: bool) -> Result<()> {
|
||||
match run.destination {
|
||||
"none" => Ok(()),
|
||||
"saveAndClose" => {
|
||||
if !source_path.is_empty() {
|
||||
s.execute("document.save", &json!({"path": source_path}))?;
|
||||
} else {
|
||||
s.execute("document.save", &json!({}))?;
|
||||
}
|
||||
if close {
|
||||
s.execute("file.close", &json!({}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"folder" => {
|
||||
let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?;
|
||||
let out_ext = if ext.is_empty() { "vectorcraft" } else { ext };
|
||||
let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}"));
|
||||
let out = out.to_string_lossy().into_owned();
|
||||
// When `close` is false (data sets), write a copy so the template keeps its path.
|
||||
write_result(s, &out, out_ext, !close)?;
|
||||
if close {
|
||||
s.execute("file.close", &json!({}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
|
||||
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result<()> {
|
||||
use super::SAVE_FORMATS;
|
||||
let id = if ext == "drawcraft" { "vectorcraft" } else { ext };
|
||||
if SAVE_FORMATS.contains(&id) {
|
||||
let cmd = if as_copy { "file.saveCopy" } else { "document.save" };
|
||||
s.execute(cmd, &json!({"path": path}))?;
|
||||
} else {
|
||||
s.execute("document.export", &json!({"path": path, "format": id}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rewrite_paths(
|
||||
steps: &[Value],
|
||||
override_save: bool,
|
||||
override_export: bool,
|
||||
dest_folder: Option<&str>,
|
||||
stem: &str,
|
||||
default_ext: &str,
|
||||
) -> Vec<Value> {
|
||||
let Some(folder) = dest_folder else {
|
||||
return steps.to_vec();
|
||||
};
|
||||
steps
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let mut c = c.clone();
|
||||
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
||||
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|
||||
if !rewrite {
|
||||
return c;
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||||
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
|
||||
let ext = if ext.is_empty() { default_ext } else { ext };
|
||||
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
|
||||
params.insert("path".into(), json!(file.to_string_lossy()));
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.insert("params".into(), Value::Object(params));
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Readable files under `folder` (and optionally its subfolders), capped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::list_dir;
|
||||
|
||||
if depth > MAX_BATCH_DEPTH {
|
||||
return Err(bad("action.batch", format!("folders deeper than {MAX_BATCH_DEPTH} levels")));
|
||||
}
|
||||
let (entries, _) = list_dir(folder, MAX_BATCH_FILES.saturating_mul(2))?;
|
||||
let mut files = Vec::new();
|
||||
let mut subdirs = Vec::new();
|
||||
for e in entries {
|
||||
let name = e.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if name.is_empty() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
let path = PathBuf::from(folder).join(name);
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
match kind {
|
||||
"file" if is_openable(&path) => files.push(path),
|
||||
"folder" if include_subdirs => subdirs.push(path),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
for sub in subdirs {
|
||||
let mut nested = collect_files(&sub, true, depth + 1)?;
|
||||
files.append(&mut nested);
|
||||
if files.len() > MAX_BATCH_FILES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_openable(path: &str) -> bool {
|
||||
use super::{FORMATS, OPEN_EXTS, extension};
|
||||
let ext = extension(path);
|
||||
if ext.is_empty() {
|
||||
return false;
|
||||
}
|
||||
OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-action-batch-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(
|
||||
&p,
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="80">
|
||||
<rect x="10" y="10" width="40" height="30" fill="{fill}"/></svg>"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_folder_runs_action_and_saves_to_destination() {
|
||||
let src = tmp("src");
|
||||
let dest = tmp("dest");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src, "b.svg", "#0f0");
|
||||
let mut s = Session::new();
|
||||
// Need a document so command.batch's has_doc passes after the first open leaves one.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}],
|
||||
"label": "Unlock"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert_eq!(r["failed"], 0);
|
||||
assert!(dest.join("a.svg").is_file(), "a.svg written");
|
||||
assert!(dest.join("b.svg").is_file(), "b.svg written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_open_steps_when_override_open() {
|
||||
let src = tmp("open");
|
||||
let dest = tmp("open-out");
|
||||
write_svg(&src, "only.svg", "#00f");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"overrideOpen": true,
|
||||
"commands": [
|
||||
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
|
||||
{"command": "object.unlockAll", "params": {}}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert!(dest.join("only.svg").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_datasets_applies_each_row() {
|
||||
let dest = tmp("sets");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}],
|
||||
"label": "Per set"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
let written: Vec<_> = std::fs::read_dir(&dest).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
assert!(s.execute("action.batch", &json!({"commands": []})).is_err());
|
||||
assert!(
|
||||
s.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": ".",
|
||||
"commands": [{"command": "command.batch", "params": {"commands": []}}]
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
|
||||
Ok(d) => {
|
||||
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
|
||||
b.document(&d)?;
|
||||
b.doc.limit_depth_noting(&mut b.warnings);
|
||||
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
|
||||
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
|
||||
Ok(Imported { doc: b.doc, warnings, preview_only: false })
|
||||
|
||||
@@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
|
||||
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
|
||||
// would otherwise run inside it and take its baseline as theirs (#1697).
|
||||
if s.active().is_some() {
|
||||
super::super::edit::settle_interaction(s)?;
|
||||
}
|
||||
// The documents as they are now, whole: what a failed step brings back. Their art, history,
|
||||
// file, format and saved state share their data with the open ones until a step changes them.
|
||||
let start = s.docs.clone();
|
||||
let active = s.active;
|
||||
s.batch_stash = Some(vec![]);
|
||||
let r = run_steps(s, &label, artboard, &mut cmds);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
roll_back(s, &start, stash);
|
||||
roll_back(s, start, &stash);
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
|
||||
s.pending_paint = None;
|
||||
@@ -47,7 +55,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
|
||||
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
|
||||
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
|
||||
/// document's last artboard, the last one.
|
||||
/// document's last artboard, the last one. A step that resizes the active artboard
|
||||
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
|
||||
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
let mut results = Vec::with_capacity(cmds.len());
|
||||
for c in cmds {
|
||||
@@ -59,13 +68,18 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
let mut params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let key = if crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) {
|
||||
Some("artboard")
|
||||
} else {
|
||||
crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index")
|
||||
};
|
||||
if let Some(a) = artboard
|
||||
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(key) = key
|
||||
&& params.get(key).is_none()
|
||||
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
}
|
||||
let (v, noted) =
|
||||
s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
|
||||
@@ -81,22 +95,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
|
||||
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
|
||||
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
|
||||
let mut open = std::mem::take(&mut s.docs);
|
||||
for &uid in start {
|
||||
// The newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
|
||||
// Its state before the batch: the first one a step closed or replaced, else the open one.
|
||||
let st = match stash.iter().position(|d| d.uid == uid) {
|
||||
Some(i) => Some(stash.remove(i)),
|
||||
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
|
||||
};
|
||||
if let Some(mut st) = st {
|
||||
st.undo_interaction();
|
||||
st.revision = newest.saturating_add(1);
|
||||
s.docs.push(st);
|
||||
}
|
||||
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
for st in &mut s.docs {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
}
|
||||
}
|
||||
@@ -94,13 +94,15 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
|
||||
/// bounds clip them), as DXF's crop leaves them. Symbol instances are measured by their art, and
|
||||
/// the box of one whose art holds brushed strokes grows by as far as their brush art can paint
|
||||
/// ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol instance also stays when what
|
||||
/// the canvas paints for it reaches `rect` ([`vectorcraft_render::painted_bounds`]: the effects in
|
||||
/// the symbols' art and its own effects). A clipping layer keeps every object, its clipping path
|
||||
/// among them. An artboard's file then holds its own art, not a whole document of artboards.
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
|
||||
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
|
||||
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
|
||||
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
|
||||
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
|
||||
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
|
||||
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
|
||||
/// its own art, not a whole document of artboards.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
|
||||
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
|
||||
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
|
||||
@@ -109,11 +111,21 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
return Some(Arc::new(layer));
|
||||
}
|
||||
// A group that doesn't clip keeps only its members over the artboard, so art gathered in one
|
||||
// group across several artboards splits by artboard. Not with effects of its own: a shadow
|
||||
// can reach in from a member outside.
|
||||
if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() {
|
||||
let mut group = Node::clone(n);
|
||||
let children = group.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
return (!children.is_empty()).then(|| Arc::new(group));
|
||||
}
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
let mut holds_instance = false;
|
||||
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
|
||||
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
|
||||
}
|
||||
let symbols = doc.symbol_boxes().with_brush_art();
|
||||
let mut d = doc.clone();
|
||||
@@ -147,28 +159,42 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
|
||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||
return Ok(Cow::Owned(selection(s, cmd)?.0));
|
||||
}
|
||||
export_source(s.doc()?, p)
|
||||
let st = s.doc()?;
|
||||
let doc = export_source(st, p)?;
|
||||
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
|
||||
}
|
||||
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
/// visual bounds.
|
||||
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
|
||||
let ids = selected_artwork(s)?;
|
||||
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
}
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
|
||||
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
|
||||
}
|
||||
|
||||
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
|
||||
/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol
|
||||
/// instances are measured by their art), `None` when they have none.
|
||||
pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
|
||||
/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they
|
||||
/// have none).
|
||||
/// named `name`: their [`export_crop`] (`None` when they have none).
|
||||
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let bounds = export_crop(doc, ids)?;
|
||||
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
|
||||
let symbols = doc.symbol_boxes();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?;
|
||||
let mut d = single_artboard(doc, bounds, name);
|
||||
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
@@ -178,8 +204,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
|
||||
Some((d, bounds))
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
|
||||
/// template layers are guides, not artwork, and are left out.
|
||||
/// File → Export Selection: the selected objects alone, cropped to the bounds the renderer
|
||||
/// measures for what they draw, their effects included ([`isolated`]). Objects on template layers
|
||||
/// are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai`
|
||||
//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound
|
||||
//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part
|
||||
//! (#868).
|
||||
//!
|
||||
//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding
|
||||
//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several.
|
||||
//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into
|
||||
//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent
|
||||
//! objects, across layers, into one flattened result, which empties the layers under them, and
|
||||
//! editable layers are what this format is saved for. Transparent objects are written opaque (a
|
||||
//! warning says so).
|
||||
//!
|
||||
//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! the art under it stays as it was. With `omit` they are left out.
|
||||
//!
|
||||
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
|
||||
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
|
||||
//! left out, with `visible` they show where they are. Type is written as text the format's reader
|
||||
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
|
||||
//! 8-bit text can't hold always is).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, Format};
|
||||
|
||||
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
|
||||
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
|
||||
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
|
||||
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SeeThrough {
|
||||
Flatten,
|
||||
Omit,
|
||||
}
|
||||
|
||||
/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers.
|
||||
fn hidden_objects(cmd: &str, p: &Value) -> Result<vectorcraft_eps::HiddenObjects> {
|
||||
use vectorcraft_eps::HiddenObjects;
|
||||
match p.get("hiddenObjects") {
|
||||
None | Some(Value::Null) => Ok(HiddenObjects::Layer),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible),
|
||||
Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type as outlines (`outlineText`), not text objects.
|
||||
fn outline_text(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("outlineText") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
|
||||
match p.get("transparentImages") {
|
||||
None | Some(Value::Null) => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit),
|
||||
Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the format loses against a VectorCraft document, reported with every save.
|
||||
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, type, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
|
||||
const FLATTENED: &str =
|
||||
"transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images";
|
||||
const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out";
|
||||
const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own";
|
||||
|
||||
/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or
|
||||
/// `range`; default all).
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "the document has no artboard"));
|
||||
}
|
||||
let mode = see_through(cmd, p)?;
|
||||
let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?);
|
||||
let picked: ArtboardPick = super::encode::options(f, p)?;
|
||||
let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() };
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
let mut d = full.into_owned();
|
||||
expand_appearances(&mut d);
|
||||
let images = see_through_images(&d, mode);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
if mode == SeeThrough::Flatten {
|
||||
flatten_images(&mut d, &images)?;
|
||||
} else {
|
||||
for (_, id) in images {
|
||||
let _ = d.remove(id); // Inside an image left out before: gone already.
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
|
||||
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
|
||||
for w in placed.iter().chain(&out.warnings) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Expand Appearance of every layer's art whose appearance the format can't hold.
|
||||
fn expand_appearances(d: &mut Document) {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
let mut out = vec![];
|
||||
for id in layers {
|
||||
super::super::effectcmd::expand_node(d, id, &brushes, &mut out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
|
||||
/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only
|
||||
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
|
||||
/// white); all are left out with `omit`.
|
||||
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal)
|
||||
{
|
||||
out.push((at, n.id));
|
||||
}
|
||||
at += 1;
|
||||
});
|
||||
if mode == SeeThrough::Flatten {
|
||||
out.retain(|(_, id)| d.is_visible(*id));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows
|
||||
/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is
|
||||
/// rendered from `d` as it was, so images over one another flatten as they look.
|
||||
fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
|
||||
let src = d.clone();
|
||||
for &(at, id) in images {
|
||||
let Some((parent, idx, _)) = d.position(id) else { continue };
|
||||
let flat = flattened(&src, at, id)?;
|
||||
d.remove(id)?;
|
||||
if let Some(flat) = flat {
|
||||
let png = flat.png;
|
||||
let key = super::super::menucmds::unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
|
||||
group.name = src.node(id).and_then(|n| n.name.clone());
|
||||
d.insert(parent, idx, group)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to.
|
||||
struct Flat {
|
||||
png: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
xf: Affine,
|
||||
clip: PathData,
|
||||
}
|
||||
|
||||
/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own
|
||||
/// in between.
|
||||
const MAX_PPI: f64 = 600.0;
|
||||
const MIN_PPI: f64 = 72.0;
|
||||
/// A rendered pixel channel changes when it changes by more than this.
|
||||
const CHANGED: u8 = 2;
|
||||
/// The clip outline follows cells of about this many points.
|
||||
const CELL: f64 = 2.0;
|
||||
|
||||
/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and
|
||||
/// including it, over white, cropped and clipped to the cells where drawing it changes the
|
||||
/// result. `None` when it changes nothing.
|
||||
fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) };
|
||||
let NodeKind::Image(im) = &n.kind else { return Ok(None) };
|
||||
// The image's own resolution (points per pixel from its placement).
|
||||
let pt = im.xf.determinant().abs().sqrt();
|
||||
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
|
||||
let area = (bounds.width() * bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let region = Rect::new(
|
||||
bounds.x0,
|
||||
bounds.y0,
|
||||
bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale,
|
||||
bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let mut with = src.clone();
|
||||
let mut i = 0;
|
||||
with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect();
|
||||
let mut without = with.clone();
|
||||
without.remove(id)?;
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let shown = r.render_region(&with, region, scale, true);
|
||||
let under = r.render_region(&without, region, scale, true);
|
||||
let (w, h) = (shown.width as usize, shown.height as usize);
|
||||
if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
// The cells where it changes anything.
|
||||
let c = (CELL * scale).round().max(1.0) as usize;
|
||||
let (cw, ch) = (w.div_ceil(c), h.div_ceil(c));
|
||||
let mut cells = vec![false; cw * ch];
|
||||
for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() {
|
||||
if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) {
|
||||
let (x, y) = (k % w, k / w);
|
||||
if let Some(cell) = cells.get_mut(y / c * cw + x / c) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw));
|
||||
let (Some(x0), Some(x1), Some(y0), Some(y1)) =
|
||||
(marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Crop to them.
|
||||
let (px0, py0) = (x0 * c, y0 * c);
|
||||
let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h));
|
||||
let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4);
|
||||
for y in py0..py1 {
|
||||
pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default());
|
||||
}
|
||||
let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels };
|
||||
let png = crop.to_png().map_err(EngineError::Other)?;
|
||||
let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale);
|
||||
let mut clip = PathData::new(vec![]);
|
||||
for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) {
|
||||
for lp in std::iter::once(&comp.outer).chain(&comp.holes) {
|
||||
let pts: Vec<(i32, i32)> = corners(&lp.pts);
|
||||
let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect();
|
||||
clip.subpaths.push(SubPath::new(anchors, true));
|
||||
}
|
||||
}
|
||||
let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip }))
|
||||
}
|
||||
|
||||
/// `n` without the nodes after the `last`th in paint order (`i` counts them, as
|
||||
/// [`Document::walk`] does); `None` when `n` itself comes after it.
|
||||
fn up_to(n: &Node, last: usize, i: &mut usize) -> Option<Node> {
|
||||
if *i > last {
|
||||
return None;
|
||||
}
|
||||
*i += 1;
|
||||
let mut n = n.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let kept: Vec<Arc<Node>> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect();
|
||||
*ch = kept;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// A loop's corners: its points without those in the middle of a straight run.
|
||||
fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> {
|
||||
let n = pts.len();
|
||||
let get = |k: usize| pts.get(k % n.max(1)).copied();
|
||||
(0..n)
|
||||
.filter_map(|k| {
|
||||
let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?);
|
||||
let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1);
|
||||
(!straight).then_some(b)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(warnings: &mut Vec<String>, w: &str) {
|
||||
if !warnings.iter().any(|x| x == w) {
|
||||
warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user