mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-17 04:36:54 -04:00
Compare commits
748
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae82ed6500 | ||
|
|
40dd19d17a | ||
|
|
8148d024ea | ||
|
|
68c9d440ab | ||
|
|
bde7140396 | ||
|
|
cdb0bbe315 | ||
|
|
32e22891d6 | ||
|
|
69a90f6033 | ||
|
|
14de645441 | ||
|
|
b9c3dd4519 | ||
|
|
b9d370aeff | ||
|
|
b9b681c748 | ||
|
|
78330cfa2f | ||
|
|
187a75ca3f | ||
|
|
13df37f9d1 | ||
|
|
8f71c94473 | ||
|
|
8511d0321b | ||
|
|
1e87e85ab7 | ||
|
|
c5d2297ef1 | ||
|
|
bf785840e5 | ||
|
|
3fc78187ec | ||
|
|
5a3e833ec8 | ||
|
|
5bdd9d539f | ||
|
|
a285efda46 | ||
|
|
d5e44b321e | ||
|
|
6902e1ab3f | ||
|
|
f0bb2760c9 | ||
|
|
a71f8eb56a | ||
|
|
a8e45f0e2d | ||
|
|
51f8582c25 | ||
|
|
9122ed0a7e | ||
|
|
6920adf092 | ||
|
|
1eb9bea82a | ||
|
|
d55b4f77cf | ||
|
|
8b8c164e3f | ||
|
|
4ed644d98d | ||
|
|
26644850fc | ||
|
|
8735dbc6fd | ||
|
|
c266083acd | ||
|
|
a3dc3ca68e | ||
|
|
d82eafce7a | ||
|
|
f3ff11ff45 | ||
|
|
cb33850e9c | ||
|
|
a1372d498a | ||
|
|
b9cc13beff | ||
|
|
b5cadb9412 | ||
|
|
981f28469e | ||
|
|
0072594442 | ||
|
|
d27a356007 | ||
|
|
02a0ad2541 | ||
|
|
71eae2559d | ||
|
|
c61fd7085f | ||
|
|
e9c6c53b2a | ||
|
|
8aef393aab | ||
|
|
c94f93ddc0 | ||
|
|
8b8607db8c | ||
|
|
c600673648 | ||
|
|
63fc56f327 | ||
|
|
da90552ee5 | ||
|
|
d60f3b12ff | ||
|
|
92fa9cbab2 | ||
|
|
8d185babb4 | ||
|
|
0e3ce7744b | ||
|
|
d5a3422dcd | ||
|
|
1d19d84493 | ||
|
|
1686e6748e | ||
|
|
d67290253d | ||
|
|
4064ef8656 | ||
|
|
ffd343f058 | ||
|
|
938fb5bce6 | ||
|
|
f5241fabd0 | ||
|
|
b81d8ccf6c | ||
|
|
ce45d2a79c | ||
|
|
2469a77ad4 | ||
|
|
fd28438c57 | ||
|
|
27b5eb7100 | ||
|
|
38314db4ba | ||
|
|
1aaa81f56b | ||
|
|
d5561d6e02 | ||
|
|
0746519517 | ||
|
|
8fead43ed2 | ||
|
|
4dcd0b6caa | ||
|
|
285a3da81a | ||
|
|
4f30e52368 | ||
|
|
d3751cc07b | ||
|
|
7cd6b5d983 | ||
|
|
8875b3fbca | ||
|
|
2108c3cc99 | ||
|
|
848e7a296a | ||
|
|
101fd55fa0 | ||
|
|
6939a481a4 | ||
|
|
b28e8490c2 | ||
|
|
b7018a3370 | ||
|
|
a6012d86e8 | ||
|
|
27230233cc | ||
|
|
ee61583606 | ||
|
|
92647fa5b6 | ||
|
|
1e3886c053 | ||
|
|
5eea7e4f66 | ||
|
|
2e9870b1d0 | ||
|
|
951f7c2842 | ||
|
|
34ae88292d | ||
|
|
a8fbae1b1f | ||
|
|
3c5d0e7ce4 | ||
|
|
fbcb217789 | ||
|
|
7c80b286c7 | ||
|
|
91fa3af21d | ||
|
|
577239d734 | ||
|
|
39979d4753 | ||
|
|
424a49604c | ||
|
|
47b6b3285d | ||
|
|
ecbbba4f2e | ||
|
|
2a3efa02f2 | ||
|
|
8ac4c9d268 | ||
|
|
d39e02b3fe | ||
|
|
5fd7fd4312 | ||
|
|
57c79dc0b4 | ||
|
|
1fbd35560d | ||
|
|
fdc08e25d6 | ||
|
|
7e423f91e9 | ||
|
|
1ff7ac7557 | ||
|
|
476d228262 | ||
|
|
6961bed99d | ||
|
|
37dfdd5da5 | ||
|
|
743930ac11 | ||
|
|
efdb04db8a | ||
|
|
80685831ac | ||
|
|
3688ad7a46 | ||
|
|
8ca4cd2cbf | ||
|
|
3d2e8bbe82 | ||
|
|
0af1741e24 | ||
|
|
db2bbcf405 | ||
|
|
9bee299dd5 | ||
|
|
0f987812b0 | ||
|
|
6b0d1ed1c0 | ||
|
|
49bdc5cdd4 | ||
|
|
7a8387f467 | ||
|
|
12758df31f | ||
|
|
a76faaa21f | ||
|
|
21a9da7360 | ||
|
|
f31d91bf1a | ||
|
|
52771905e6 | ||
|
|
0ffe0c7c0e | ||
|
|
8689863fbf | ||
|
|
2d0da1472f | ||
|
|
816097c6bf | ||
|
|
1d7ad173aa | ||
|
|
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 | ||
|
|
e01a65d8a9 | ||
|
|
a699a26221 |
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
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -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 |
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
Generated
+83
-77
@@ -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"
|
||||
@@ -1304,12 +1315,6 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1378,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",
|
||||
]
|
||||
@@ -1766,9 +1760,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
@@ -1782,9 +1776,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1794,28 +1787,27 @@ dependencies = [
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.46.2",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.4.1"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"fearless_simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1826,9 +1818,8 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -1837,6 +1828,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -2227,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",
|
||||
@@ -3839,16 +3830,6 @@ dependencies = [
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.46.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.43.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
@@ -4011,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"
|
||||
@@ -4395,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"
|
||||
@@ -4506,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4541,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4552,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4564,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4580,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4602,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4614,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4631,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",
|
||||
@@ -4646,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4682,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4703,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4718,7 +4720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4730,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4749,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4763,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4775,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",
|
||||
@@ -4805,7 +4807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4819,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4835,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",
|
||||
@@ -4875,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4892,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4912,7 +4916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4920,7 +4924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4928,6 +4932,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
@@ -4935,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4947,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4976,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",
|
||||
@@ -5003,7 +5009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -5018,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -6090,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+18
-4
@@ -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,9 +61,14 @@ 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"
|
||||
# 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"] }
|
||||
@@ -111,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.
|
||||
|
||||
+21
-8
@@ -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 (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 75 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; 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,11 +128,19 @@ 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-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
|
||||
| 2026-10-17 | About 75 community PRs landed in three batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 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). |
|
||||
@@ -168,6 +179,8 @@ 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. |
|
||||
|
||||
@@ -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.
|
||||
@@ -153,6 +159,15 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
|
||||
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
|
||||
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_pdf_preset_store(&mut h.session);
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
@@ -192,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
Box::new(Headless::with_document())
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
} else {
|
||||
match Remote::connect(DEFAULT_ADDR) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => Box::new(Headless::with_document()),
|
||||
Err(_) => Box::new(with_stores(Headless::with_document())),
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
@@ -207,8 +222,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
// The server reads and writes from threads of its own, so it takes the handles, not locks held here.
|
||||
Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands() -> Result<(), String> {
|
||||
@@ -223,6 +238,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 +251,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 +271,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.
|
||||
@@ -312,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut h = with_stores(Headless::new());
|
||||
let mut out = std::io::stdout().lock();
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
|
||||
/// that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
|
||||
}
|
||||
|
||||
/// Removes `dir` and everything in it, creates it again empty and returns it.
|
||||
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
fn mcp_automation_roots() {
|
||||
let base = tmp("roots");
|
||||
automation_roots(&tmp("roots"));
|
||||
}
|
||||
|
||||
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
|
||||
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
|
||||
/// with "File exists".
|
||||
#[test]
|
||||
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
|
||||
let dir = tmp("earlier-run");
|
||||
automation_roots(&dir.join("roots"));
|
||||
automation_roots(&empty_dir(dir).join("roots"));
|
||||
}
|
||||
|
||||
fn automation_roots(base: &std::path::Path) {
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
@@ -346,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the
|
||||
/// developer's real profile.
|
||||
fn config_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = tmp(&format!("config-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
|
||||
/// the result of each step.
|
||||
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
|
||||
for (id, params) in steps {
|
||||
cmd.args(["--cmd", id, "--params", ¶ms.to_string()]);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()["result"].clone()).collect()
|
||||
}
|
||||
|
||||
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
|
||||
/// name of the same length).
|
||||
fn icc_file_named(path: &std::path::Path, name: &str) {
|
||||
let builtin = "Display P3";
|
||||
assert_eq!(builtin.len(), name.len());
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(name));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile");
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn profile_names(settings: &Value) -> Vec<&str> {
|
||||
settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in
|
||||
/// the next run, and gone again once deleted.
|
||||
#[test]
|
||||
fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
|
||||
let config = config_dir("restart");
|
||||
let icc = tmp("studio-p3.icc");
|
||||
icc_file_named(&icc, "StudioRGB3");
|
||||
let r = run_in(
|
||||
&config,
|
||||
&[
|
||||
("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})),
|
||||
("color.loadProfile", json!({"path": icc.to_str().unwrap()})),
|
||||
],
|
||||
);
|
||||
assert_eq!(r[1]["name"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
let presets: Vec<&str> =
|
||||
r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(presets, ["Press"]);
|
||||
assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer");
|
||||
assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1]));
|
||||
// The profile works as a working space, and the file it came from can go.
|
||||
std::fs::remove_file(&icc).unwrap();
|
||||
let steps = [
|
||||
("edit.colorSettings", json!({"rgb": "StudioRGB3"})),
|
||||
("pdf.preset.delete", json!({"name": "Press"})),
|
||||
("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})),
|
||||
("color.removeProfile", json!({"name": "StudioRGB3"})),
|
||||
];
|
||||
let r = run_in(&config, &steps);
|
||||
assert_eq!(r[0]["rgb"], "StudioRGB3");
|
||||
assert_eq!(r[3]["removed"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
assert!(!profile_names(&r[1]).contains(&"StudioRGB3"));
|
||||
}
|
||||
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and
|
||||
/// leave nothing in the settings folder.
|
||||
#[test]
|
||||
fn runs_without_preferences_remember_nothing() {
|
||||
let config = config_dir("noprefs");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &config)
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!config.exists(), "nothing written");
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
}
|
||||
@@ -8,9 +8,16 @@ use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
|
||||
/// removes that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
let dir = DIR.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
});
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
@@ -86,7 +93,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 +125,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,6 +444,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
// 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),
|
||||
@@ -444,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Linux Wayland: tell winit where the cursor theme is (#2450).
|
||||
//!
|
||||
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
|
||||
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
|
||||
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
|
||||
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
|
||||
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
|
||||
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
|
||||
//!
|
||||
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
|
||||
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
|
||||
//!
|
||||
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
|
||||
//! any thread starts and before the window opens.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
|
||||
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
|
||||
|
||||
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct DesktopCursor {
|
||||
pub theme: Option<String>,
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// The variables to set, given the environment, the host icon folders that exist and the
|
||||
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
|
||||
/// stay as they are.
|
||||
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
|
||||
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
|
||||
}
|
||||
if !set("XCURSOR_THEME")
|
||||
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
|
||||
{
|
||||
out.push(("XCURSOR_THEME", OsString::from(theme)));
|
||||
}
|
||||
if !set("XCURSOR_SIZE")
|
||||
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
|
||||
{
|
||||
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
|
||||
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
|
||||
|
||||
/// The themes `index.theme` lists after `Inherits=`.
|
||||
fn inherits(index: &str) -> Vec<String> {
|
||||
index
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("Inherits="))
|
||||
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
|
||||
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
|
||||
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
|
||||
/// whether a file is there.
|
||||
pub fn theme_usable(
|
||||
var: impl Fn(&str) -> Option<OsString>,
|
||||
home: Option<&Path>,
|
||||
read: impl Fn(&Path) -> Option<String>,
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> bool {
|
||||
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
|
||||
Some(v) => std::env::split_paths(&v).collect(),
|
||||
None => DEFAULT_ICON_DIRS
|
||||
.iter()
|
||||
.filter_map(|d| match d.strip_prefix("~/") {
|
||||
Some(rest) => home.map(|h| h.join(rest)),
|
||||
None => Some(PathBuf::from(d)),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
|
||||
let mut todo = vec![first];
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
while let Some(theme) = todo.pop() {
|
||||
// Bounded: themes inherit in short chains, and a cycle must end.
|
||||
if seen.contains(&theme) || seen.len() >= 16 {
|
||||
continue;
|
||||
}
|
||||
for dir in &dirs {
|
||||
let base = dir.join(&theme);
|
||||
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
|
||||
return true;
|
||||
}
|
||||
if let Some(index) = read(&base.join("index.theme")) {
|
||||
todo.extend(inherits(&index));
|
||||
}
|
||||
}
|
||||
seen.push(theme);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
|
||||
/// sets a pointer, and the canvas must not hide the system one (#2450).
|
||||
pub fn theme_missing() -> bool {
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
|
||||
}
|
||||
|
||||
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
|
||||
fn gsettings_value(output: &str) -> &str {
|
||||
output.trim().trim_matches('\'')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_cursor() -> DesktopCursor {
|
||||
let get = |key: &str| {
|
||||
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
};
|
||||
DesktopCursor {
|
||||
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
|
||||
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
|
||||
/// unset. Call first thing in `main`, while the process is single-threaded.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn apply() {
|
||||
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
|
||||
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
|
||||
log::debug!("cursor theme: {name}={value:?}");
|
||||
// SAFETY: called at the top of `main`, before the app starts any thread.
|
||||
unsafe { std::env::set_var(name, value) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn adwaita() -> DesktopCursor {
|
||||
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
|
||||
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
|
||||
("XCURSOR_THEME", OsString::from("Adwaita")),
|
||||
("XCURSOR_SIZE", OsString::from("24")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_the_user_made_and_other_sessions_stay_untouched() {
|
||||
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
|
||||
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
// A theme-less desktop and an absurd size set nothing.
|
||||
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
|
||||
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
|
||||
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
|
||||
// Compared as paths, so the joins match on Windows too, where they use `\`.
|
||||
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
|
||||
let read = |p: &Path| {
|
||||
if p == Path::new("/i/Breeze/index.theme") {
|
||||
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
|
||||
} else if p == Path::new("/i/Loop/index.theme") {
|
||||
Some("Inherits=Loop\n".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let with = |theme: &'static str| {
|
||||
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
|
||||
};
|
||||
assert!(theme_usable(with("Bibata"), None, read, exists));
|
||||
assert!(theme_usable(with("Breeze"), None, read, exists));
|
||||
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
|
||||
assert!(!theme_usable(with("Nope"), None, read, exists));
|
||||
assert!(!theme_usable(with("Loop"), None, read, exists));
|
||||
// Unset: Xcursor's default folders and the `default` theme.
|
||||
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gsettings_output_is_unquoted() {
|
||||
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
|
||||
assert_eq!(gsettings_value("24\n"), "24");
|
||||
}
|
||||
}
|
||||
+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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
|
||||
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
|
||||
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
|
||||
//! The panel gets a Format menu as its accessory view
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
|
||||
//! `beginSheetModalForWindow:completionHandler:`
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
|
||||
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
|
||||
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
|
||||
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
|
||||
//! one. The save command takes the format from the extension, so an extension typed by hand still
|
||||
//! decides it.
|
||||
//!
|
||||
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
|
||||
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
|
||||
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
|
||||
//! the menu's action have no safe API; this module allows `unsafe_code`.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
/// The accessory's height and the room around and between its label and menu, in points.
|
||||
const HEIGHT: f64 = 38.0;
|
||||
const MARGIN: f64 = 12.0;
|
||||
const GAP: f64 = 8.0;
|
||||
/// The Format menu's size, in points.
|
||||
const MENU: NSSize = NSSize::new(260.0, 26.0);
|
||||
|
||||
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(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
|
||||
set_allowed_types(&panel, &self.ivars().filters, index);
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
|
||||
/// two remain, and the panel is then left to rfd.
|
||||
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
|
||||
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if named.len() < 2 { Vec::new() } else { named }
|
||||
}
|
||||
|
||||
/// The menu entry of the suggested name's type, or the first (the request's default).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = fileio::extension(name);
|
||||
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
|
||||
/// writes) is replaced, and other dots stay part of the name (a version number).
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let has = fileio::extension(name);
|
||||
let known =
|
||||
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
|
||||
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
|
||||
}
|
||||
|
||||
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
|
||||
/// the first one to a name typed without an extension, and a name typed with another of them is
|
||||
/// saved in that format.
|
||||
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
|
||||
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
|
||||
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
|
||||
first.iter().chain(others).copied().collect()
|
||||
}
|
||||
|
||||
/// Set `panel`'s allowed file types ([`allowed_types`]).
|
||||
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
|
||||
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
|
||||
#[allow(deprecated)]
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
if !pick.name.is_empty() {
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
|
||||
}
|
||||
let accessory = accessory(mtm, &panel, filters, selected);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
let (tx, rx) = channel();
|
||||
let answered = panel.clone();
|
||||
let handler = RcBlock::new(move |response: NSModalResponse| {
|
||||
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
|
||||
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(paths);
|
||||
});
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.sizeToFit();
|
||||
let size = label.frame().size;
|
||||
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
|
||||
let x = MARGIN + size.width + GAP;
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
|
||||
for (name, _) in &filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", 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);
|
||||
}
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
|
||||
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
|
||||
// sheet is open. The selector has the target/action signature NSControl calls it with.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
}
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
accessory
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
|
||||
/// named type, or one for every file, gets no menu, and rfd shows it.
|
||||
#[test]
|
||||
fn save_panels_with_several_formats_get_the_menu() {
|
||||
let save = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
assert!(save.len() >= 2, "{save:?}");
|
||||
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
|
||||
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
|
||||
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
|
||||
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
|
||||
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
|
||||
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
|
||||
for (name, expected) in [
|
||||
("図面 1.vectorcraft", "図面 1.svg"),
|
||||
("a.PDF", "a.svg"),
|
||||
("art", "art.svg"),
|
||||
("art.v2", "art.v2.svg"),
|
||||
("art.svg", "art.svg"),
|
||||
("old.drawcraft", "old.svg"),
|
||||
("photo.png", "photo.svg"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
|
||||
/// type. The others stay allowed.
|
||||
#[test]
|
||||
fn the_chosen_format_is_the_first_allowed_type() {
|
||||
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
|
||||
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
for (label, extensions) in &filters {
|
||||
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's label is translated in every language with complete menus.
|
||||
#[test]
|
||||
fn the_label_is_translated() {
|
||||
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -27,6 +27,7 @@ mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
mod cursor_theme;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
mod logging;
|
||||
@@ -35,6 +36,10 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_screen_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
@@ -471,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
// The panel is built and the sheet is begun below, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
@@ -483,12 +488,18 @@ 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(_) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
|
||||
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// A save panel with two or more named file types is built by `mac_save_format`, which sets
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
// 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 (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
@@ -504,6 +515,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))),
|
||||
@@ -513,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
wayland,
|
||||
no_cursor_theme: wayland && cursor_theme::theme_missing(),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
@@ -535,6 +549,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()
|
||||
@@ -618,7 +635,30 @@ 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 {
|
||||
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
|
||||
#[cfg(target_os = "linux")]
|
||||
cursor_theme::apply();
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
@@ -643,6 +683,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),
|
||||
}
|
||||
}
|
||||
@@ -726,6 +774,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
|
||||
@@ -755,13 +813,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.
|
||||
@@ -795,8 +854,14 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
@@ -836,6 +901,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());
|
||||
|
||||
@@ -24,6 +24,31 @@ pub fn install_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder `name` next to the settings, for what the user keeps: none in a run without
|
||||
/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing.
|
||||
fn store_dir(name: &str) -> Option<PathBuf> {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name))
|
||||
}
|
||||
|
||||
/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and
|
||||
/// keep the ones loaded from now on there. Before anything names a profile.
|
||||
pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Color Profiles") {
|
||||
session.attach_profile_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("PDF Presets") {
|
||||
session.attach_pdf_preset_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+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,28 @@ 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
|
||||
|
||||
[people.theromis]
|
||||
sync_github_name = true
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
|
||||
@@ -171,9 +171,25 @@ impl GenericCmyk {
|
||||
xyz_to_lab(self.to_xyz(cmyk))
|
||||
}
|
||||
|
||||
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
|
||||
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
/// With `bpc` (media-relative only) the press black displays as black, undoing the
|
||||
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
|
||||
/// darkest rich black shows as black again.
|
||||
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
if absolute {
|
||||
return self.to_xyz(cmyk);
|
||||
}
|
||||
let x = self.to_xyz_rel(cmyk);
|
||||
if !bpc {
|
||||
return x;
|
||||
}
|
||||
let bp = self.black_rel;
|
||||
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
|
||||
}
|
||||
|
||||
/// Display sRGB, see [`Self::display_xyz`].
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
@@ -425,11 +441,12 @@ impl GenericCmyk {
|
||||
self.from_lab(srgb_to_lab(rgb), intent, bpc)
|
||||
}
|
||||
|
||||
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
|
||||
pub fn gamut_error(&self, src: Lab) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
|
||||
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
|
||||
/// (with `bpc`, the press black showing as black).
|
||||
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
|
||||
let cmyk = self.separate(target);
|
||||
delta_e2000(self.to_lab_rel(cmyk), target)
|
||||
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+148
-8
@@ -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,33 @@ 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)
|
||||
}
|
||||
|
||||
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
|
||||
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
|
||||
/// caller checks [`active_settings`] first.
|
||||
pub fn unregister_icc(name: &str) -> bool {
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
let before = u.len();
|
||||
u.retain(|x| x.name != name);
|
||||
let removed = u.len() != before;
|
||||
drop(u);
|
||||
if removed {
|
||||
clear_proof_cache();
|
||||
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`]
|
||||
/// removes one: 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
|
||||
@@ -336,10 +362,10 @@ impl Cms {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
@@ -366,7 +392,7 @@ impl Cms {
|
||||
{
|
||||
return v;
|
||||
}
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
|
||||
}
|
||||
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
|
||||
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
|
||||
@@ -398,6 +424,15 @@ impl Cms {
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
|
||||
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
|
||||
/// as black). Unclipped, for interpolating tables.
|
||||
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
|
||||
_ => self.cmyk_to_lab(cmyk),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
@@ -419,7 +454,7 @@ impl Cms {
|
||||
/// CIE Lab (D50) of any colour.
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
|
||||
Color::Lab { l, a, b } => Lab::new(l, a, b),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
@@ -480,7 +515,7 @@ impl Cms {
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let src = self.lab(c);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
|
||||
@@ -505,6 +540,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)
|
||||
@@ -512,7 +548,7 @@ impl Cms {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
@@ -523,7 +559,7 @@ impl Cms {
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
@@ -572,6 +608,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 +727,8 @@ pub enum ProofTarget {
|
||||
Protanopia,
|
||||
/// Colour blindness — deuteranopia type.
|
||||
Deuteranopia,
|
||||
/// Color blindness, tritanopia type.
|
||||
Tritanopia,
|
||||
}
|
||||
|
||||
impl ProofTarget {
|
||||
@@ -605,6 +743,7 @@ impl ProofTarget {
|
||||
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
|
||||
"protanopia" => ProofTarget::Protanopia,
|
||||
"deuteranopia" => ProofTarget::Deuteranopia,
|
||||
"tritanopia" => ProofTarget::Tritanopia,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -617,6 +756,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(_))));
|
||||
@@ -216,6 +245,20 @@ fn loads_system_cmyk_icc() {
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
|
||||
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
|
||||
#[test]
|
||||
fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
let c = cms();
|
||||
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
|
||||
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
|
||||
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
|
||||
// The paper still shows as white, and without compensation the press black stays a dark grey.
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
|
||||
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
|
||||
let p = IccProfile::from_bytes(None, &bytes).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
|
||||
// The encoded profile converts without black-point compensation, so compare without it.
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
|
||||
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
|
||||
let want = cms.cmyk_to_srgb(cmyk, false);
|
||||
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
|
||||
|
||||
@@ -181,6 +181,10 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
// `from_str_radix` accepts a leading `+`, so check the digits first.
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
|
||||
match h.len() {
|
||||
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
|
||||
@@ -412,6 +416,14 @@ mod tests {
|
||||
assert_eq!(Color::from_hex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejects_sign_characters() {
|
||||
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
|
||||
assert_eq!(Color::from_hex(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_roundtrip() {
|
||||
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Cooperative cancellation and progress for long-running work (exports, flattening, baking).
|
||||
//!
|
||||
//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`)
|
||||
//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the
|
||||
//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where
|
||||
//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The
|
||||
//! token is tied to the thread that entered the scope, so concurrent sessions on other threads
|
||||
//! (and tests running in parallel) never see each other's cancellation, and code outside any
|
||||
//! scope is never cancelled.
|
||||
//!
|
||||
//! Work that stops early leaves nothing behind: callers drop whatever was half built and report
|
||||
//! the error, so a cancelled command changes nothing.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// A flag one thread raises and another polls. Cloning shares the flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Ask for the work holding this token to stop.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of work that stopped because it was cancelled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Cancelled;
|
||||
|
||||
impl std::fmt::Display for Cancelled {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Cancelled {}
|
||||
|
||||
/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known.
|
||||
pub type ProgressSink = Rc<dyn Fn(u64, u64, &str)>;
|
||||
|
||||
struct Scope {
|
||||
token: CancelToken,
|
||||
sink: Option<ProgressSink>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Puts the previous scope back, also when the work inside unwinds.
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
// `try_with`: during thread teardown the slot may be gone; nothing to restore then.
|
||||
let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread.
|
||||
pub fn scope<T>(token: &CancelToken, sink: Option<ProgressSink>, f: impl FnOnce() -> T) -> T {
|
||||
let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink }));
|
||||
let _restore = Restore(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Has the work running on this thread been cancelled? Always false outside a [`scope`].
|
||||
pub fn is_cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled()))
|
||||
}
|
||||
|
||||
/// Is this thread running inside a [`scope`], so that a cancel can reach it?
|
||||
pub fn is_cancellable() -> bool {
|
||||
CURRENT.with(|c| c.borrow().is_some())
|
||||
}
|
||||
|
||||
/// `Err(Cancelled)` when the work running on this thread has been cancelled.
|
||||
pub fn check() -> Result<(), Cancelled> {
|
||||
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
|
||||
/// that has a sink.
|
||||
pub fn progress(done: u64, total: u64, what: &str) {
|
||||
// The sink runs outside the borrow, so it may itself call back into this module.
|
||||
let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone()));
|
||||
if let Some(sink) = sink {
|
||||
sink(done, total, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_work_inside_the_scope_is_cancelled() {
|
||||
let t = CancelToken::new();
|
||||
assert_eq!(check(), Ok(()));
|
||||
scope(&t, None, || {
|
||||
assert_eq!(check(), Ok(()));
|
||||
t.cancel();
|
||||
assert_eq!(check(), Err(Cancelled));
|
||||
// A nested scope with its own token shadows, then restores.
|
||||
scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(())));
|
||||
assert!(is_cancelled());
|
||||
});
|
||||
assert!(!is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_reaches_the_sink_and_the_scope_survives_a_panic() {
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let sink: ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let t = CancelToken::new();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
scope(&t, Some(sink), || {
|
||||
progress(1, 3, "page");
|
||||
panic!("boom");
|
||||
})
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
progress(2, 3, "after");
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
|
||||
}
|
||||
}
|
||||
+203
-34
@@ -2,10 +2,12 @@
|
||||
//!
|
||||
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
|
||||
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
|
||||
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
|
||||
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
|
||||
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
|
||||
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
|
||||
//! decodes to RGB(A) only.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zune_jpeg::zune_core::bytestream::ZCursor;
|
||||
use zune_jpeg::zune_core::colorspace::ColorSpace;
|
||||
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
|
||||
}
|
||||
|
||||
/// The colour type of TIFF `bytes` and its decoder.
|
||||
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
|
||||
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
|
||||
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
|
||||
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
|
||||
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
|
||||
/// wide, claims at least 2^32 samples.
|
||||
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
|
||||
/// and for bytes that aren't a TIFF.
|
||||
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let le = bytes.starts_with(b"II*\0");
|
||||
if !le && !bytes.starts_with(b"MM\0*") {
|
||||
return None;
|
||||
}
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
|
||||
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
|
||||
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
|
||||
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
|
||||
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
|
||||
let value = match (ty, count) {
|
||||
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
|
||||
(3, 1) => u16_at(at).map(u32::from),
|
||||
(4 | 13, 1) => u32_at(at),
|
||||
(16 | 18, 1) => {
|
||||
let at = usize::try_from(u32_at(at)?).ok()?;
|
||||
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
|
||||
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
|
||||
u32::try_from(wide).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some((u16_at(e)?, value))
|
||||
};
|
||||
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
|
||||
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
|
||||
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let Some((tag, value)) = entry(i) else { break };
|
||||
let Some(v) = value else { continue };
|
||||
match tag {
|
||||
256 => width = width.max(v),
|
||||
257 => height = height.max(v),
|
||||
277 => planes = planes.max(v),
|
||||
278 => rows = least(rows, v),
|
||||
284 => planar |= v == 2,
|
||||
322 => tile_w = least(tile_w, v),
|
||||
323 => tile_h = least(tile_h, v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let planes = if planar { u128::from(planes) } else { 1 };
|
||||
let tiles = match (tile_w, tile_h) {
|
||||
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
|
||||
_ => false,
|
||||
};
|
||||
let rows = rows.unwrap_or(height);
|
||||
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
|
||||
(tiles || strips).then_some((width, height))
|
||||
}
|
||||
|
||||
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
|
||||
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
if !is_tiff(bytes) {
|
||||
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
|
||||
Some((d.colortype().ok()?, d))
|
||||
}
|
||||
|
||||
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
|
||||
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
|
||||
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
|
||||
}
|
||||
|
||||
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
|
||||
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
|
||||
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
|
||||
/// that many samples, which includes images whose planes together need more than the decoder's
|
||||
/// limit (256 MB of samples).
|
||||
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
|
||||
let layout = d.read_image_to_buffer(&mut buf).ok()?;
|
||||
let data = match buf {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(per) {
|
||||
return None;
|
||||
}
|
||||
if layout.planes <= 1 {
|
||||
return Some(data);
|
||||
}
|
||||
if layout.planes != per {
|
||||
return None;
|
||||
}
|
||||
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
|
||||
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
|
||||
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
Self::cmyk_tiff_at(inks, None)
|
||||
}
|
||||
|
||||
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
|
||||
/// inch) when it is given and both are finite and above 0.
|
||||
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
TiffEncoder::new(&mut out)
|
||||
.ok()?
|
||||
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
|
||||
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
|
||||
.ok()?;
|
||||
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
|
||||
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
|
||||
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
|
||||
// Ten-thousandths of a pixel per inch.
|
||||
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
|
||||
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
|
||||
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
|
||||
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
|
||||
}
|
||||
img.write_data(&inks.data).ok()?;
|
||||
Some(Self::new("image/tiff", out.into_inner()))
|
||||
}
|
||||
|
||||
@@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, data)
|
||||
}
|
||||
|
||||
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
|
||||
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
|
||||
/// per ink.
|
||||
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
// Planar samples (one plane per ink) would read as interleaved.
|
||||
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
|
||||
return None;
|
||||
}
|
||||
let data = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
Inks::new(w, h, data)
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
Inks::new(w, h, read_samples(&mut d, n, 4)?)
|
||||
}
|
||||
|
||||
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
|
||||
/// ink, which the image decoder doesn't read, their ink amounts written again by
|
||||
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
|
||||
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
|
||||
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
|
||||
let inks = tiff_inks(bytes)?;
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
if !planar(&mut d) {
|
||||
return Some(bytes.to_vec());
|
||||
}
|
||||
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
|
||||
}
|
||||
|
||||
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
|
||||
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
|
||||
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
|
||||
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
|
||||
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
|
||||
/// stored together or one plane per channel. `None` for other images and ones larger than
|
||||
/// [`MAX_CMYK_PIXELS`].
|
||||
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
// 0: white is zero, 1: black is zero (the two grayscales).
|
||||
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
|
||||
@@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if planar {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
let data: Vec<u8> = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(samples) {
|
||||
return None;
|
||||
}
|
||||
let data = read_samples(&mut d, n, samples)?;
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for p in data.chunks_exact(samples) {
|
||||
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
|
||||
@@ -246,6 +357,64 @@ mod tests {
|
||||
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
|
||||
}
|
||||
|
||||
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
|
||||
/// times 2 planes.
|
||||
#[test]
|
||||
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
|
||||
let header = |planar: u32| {
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
for (tag, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
|
||||
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f
|
||||
};
|
||||
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
|
||||
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
|
||||
assert!(tiff(&header(2)).is_none(), "not decoded");
|
||||
}
|
||||
|
||||
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
|
||||
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
|
||||
/// reads a LONG8 value from where the entry points.
|
||||
#[test]
|
||||
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
|
||||
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
|
||||
let header = |entries: &[(u16, u16, u32)]| {
|
||||
let after = 8 + 2 + 12 * entries.len() + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
let mut wide = vec![];
|
||||
for &(tag, ty, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
|
||||
f.extend(1u32.to_le_bytes());
|
||||
if ty == 16 {
|
||||
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
|
||||
wide.extend(u64::from(v).to_le_bytes());
|
||||
} else {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f.extend(wide);
|
||||
f
|
||||
};
|
||||
let tall = 1u32 << 31 | 1;
|
||||
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
|
||||
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
|
||||
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
|
||||
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
|
||||
let mut long8 = strips;
|
||||
long8[1] = (257, 16, tall);
|
||||
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
|
||||
for h in [header(&tile_sizes), header(&long8)] {
|
||||
assert!(tiff(&h).is_none(), "not decoded");
|
||||
}
|
||||
}
|
||||
|
||||
fn inks() -> Inks {
|
||||
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
|
||||
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
||||
pub corners: Vec<Corner>,
|
||||
radii: Vec<f64>,
|
||||
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
|
||||
drawn: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
|
||||
let corners = path_corners(&base);
|
||||
let drawn = fitted_radii(&corners, &radii);
|
||||
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
|
||||
/// neighbours leave room for (0 for an anchor that is no corner).
|
||||
pub fn drawn_radius(&self, index: usize) -> f64 {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
|
||||
}
|
||||
/// The kind corner `index` is cut with.
|
||||
pub fn kind(&self, index: usize) -> CornerKind {
|
||||
self.kinds.get(index).copied().unwrap_or_default()
|
||||
|
||||
@@ -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,37 @@ 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(",");
|
||||
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
|
||||
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
|
||||
let field = |c: &str, always: bool| {
|
||||
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
|
||||
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
|
||||
format!("\"{}\"", c.replace('"', "\"\""))
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
};
|
||||
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !self.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
|
||||
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);
|
||||
}
|
||||
}
|
||||
+153
-11
@@ -8,18 +8,25 @@
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod cancel;
|
||||
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;
|
||||
@@ -106,9 +113,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};
|
||||
@@ -119,6 +126,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}")]
|
||||
@@ -232,7 +242,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())
|
||||
}
|
||||
@@ -269,20 +282,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)
|
||||
@@ -302,7 +336,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))
|
||||
@@ -329,6 +366,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> {
|
||||
@@ -686,6 +731,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,
|
||||
@@ -706,6 +755,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)
|
||||
@@ -766,6 +822,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -1399,6 +1456,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();
|
||||
@@ -1493,6 +1557,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> {
|
||||
@@ -1768,6 +1902,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());
|
||||
|
||||
@@ -94,26 +94,27 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
|
||||
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
|
||||
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
|
||||
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
|
||||
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
|
||||
/// that colour, where Multiply would darken it. With `discard_white`
|
||||
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
|
||||
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
if !a.has_overprint() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
let multiplies =
|
||||
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
multiply_overprints(c, discard_white);
|
||||
darken_overprints(c, discard_white);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,20 @@ use crate::ImageBlob;
|
||||
/// 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).
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
|
||||
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
|
||||
/// the header first: the decoder's limit is on the file's samples, and a gray 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 {
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(max);
|
||||
reader.limits(limits);
|
||||
Some(reader.decode().ok()?.into_rgba8())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
@@ -131,6 +136,35 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
|
||||
/// other pixels, and the decoder makes them transparent.
|
||||
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend_from_slice(&w.to_le_bytes());
|
||||
gif.extend_from_slice(&h.to_le_bytes());
|
||||
// Flags for a global color table of two colors, the background and aspect bytes, then the
|
||||
// table: red and black.
|
||||
gif.extend_from_slice(&[0x80, 0, 0, 255, 0, 0, 0, 0, 0]);
|
||||
// A 1 × 1 image at (0, 0), its LZW data (clear, color 0, end), and the end of the file.
|
||||
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// The pixels are decoded within the allowance, not within the decoder's own 512 MB: an RGBA
|
||||
/// image of 11,586 × 11,586 pixels (536,941,584 bytes) is recolored and sampled.
|
||||
#[test]
|
||||
fn pixels_past_the_decoders_own_limit_are_decoded_within_the_allowance() {
|
||||
let (w, h) = (11_586, 11_586);
|
||||
let rgba = u64::from(w) * u64::from(h) * 4;
|
||||
assert!(rgba > 512 << 20);
|
||||
let gif = gif_with_one_red_pixel(w, h);
|
||||
assert!(rgba_within(&gif, rgba - 1).is_none(), "past the allowance");
|
||||
let b = ImageBlob::new("image/gif", gif);
|
||||
assert!(b.map_rgb(|[r, g, bl]| [bl, g, r]).is_some(), "red becomes blue");
|
||||
assert_eq!(b.sample(0.5, 0.5, 1), Some([255, 0, 0, 255]));
|
||||
assert_eq!(b.sample(f64::from(w) - 0.5, f64::from(h) - 0.5, 1), None, "transparent");
|
||||
}
|
||||
|
||||
/// #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]
|
||||
@@ -143,6 +177,38 @@ mod tests {
|
||||
assert!(rgba_within(b"not an image", u64::MAX).is_none());
|
||||
}
|
||||
|
||||
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
|
||||
/// no pixel data.
|
||||
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
|
||||
/// refused like any other past the allowance, and an image within the allowance still decodes.
|
||||
#[test]
|
||||
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
|
||||
let huge = tiff_header(1 << 31, 1 << 31);
|
||||
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
|
||||
assert!(rgba_within(&huge, u64::MAX).is_none());
|
||||
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
|
||||
}
|
||||
|
||||
#[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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+17
-12
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]).
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
|
||||
/// since a bare `_` would read back as a space.
|
||||
pub fn from_object_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
|
||||
out.push('_');
|
||||
} else if c == '_' {
|
||||
out.push_str("_x5F_");
|
||||
} else if i == 0 && c.is_ascii_digit() {
|
||||
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
|
||||
// A first character that cannot start an XML name is escaped, never given a bare `_`
|
||||
// prefix (that would read back as a space).
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if out.is_empty() || !starts_name(&out) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
|
||||
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn starts_name(id: &str) -> bool {
|
||||
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
|
||||
}
|
||||
|
||||
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
|
||||
fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
@@ -114,4 +108,15 @@ mod tests {
|
||||
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
|
||||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_names_that_cannot_start_an_xml_name() {
|
||||
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
|
||||
let id = from_object_name(name);
|
||||
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
|
||||
}
|
||||
assert_eq!(from_object_name("-abc"), "_x2D_abc");
|
||||
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
|
||||
assert_eq!(from_object_name("²x"), "_xB2_x");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
+611
-11
@@ -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] {
|
||||
@@ -81,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
|
||||
|
||||
/// `n` with its geometry effects evaluated (`None` = unchanged).
|
||||
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs_bake(n) {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
@@ -98,8 +107,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 +121,17 @@ 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) {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
|
||||
// its gradient fits it rather than spanning every copy.
|
||||
if crate::has_gradient_copies(n)
|
||||
&& let Some(m) = reshape(n, None)
|
||||
{
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// 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 +181,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 +576,18 @@ 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};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
@@ -408,4 +635,377 @@ 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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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());
|
||||
}
|
||||
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
|
||||
fn gradient_copies(id: u64) -> Node {
|
||||
use vectorcraft_color::{Gradient, 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 paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
||||
n
|
||||
}
|
||||
|
||||
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
|
||||
fn gradient_pieces(n: &Node) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
let mut walk = vec![n];
|
||||
while let Some(n) = walk.pop() {
|
||||
match n.children() {
|
||||
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
|
||||
_ => {
|
||||
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
|
||||
out.extend(n.geometric_bounds().map(|b| b.width()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
|
||||
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
|
||||
// Three squares of their own, not one path spanning all three.
|
||||
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
|
||||
}
|
||||
}
|
||||
+107
-32
@@ -103,6 +103,39 @@ 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;
|
||||
@@ -112,6 +145,17 @@ const MAX_REACH: f64 = 1.0e9;
|
||||
/// 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
|
||||
@@ -124,6 +168,36 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
|
||||
/// [`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 };
|
||||
@@ -137,28 +211,7 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
* 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);
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > max {
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
}
|
||||
out
|
||||
(m, copies)
|
||||
}
|
||||
|
||||
/// Tweak: random displacement of anchors and/or control points.
|
||||
@@ -214,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the
|
||||
/// path to the ridges before and after it (zero at an open path's ends).
|
||||
struct Ridge {
|
||||
p: Point,
|
||||
dir: Vec2,
|
||||
gap_in: f64,
|
||||
gap_out: f64,
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
|
||||
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
|
||||
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
|
||||
/// whatever the size of the ridges (#2343).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -224,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut subs = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segs = sp.segment_count();
|
||||
let mut pts = vec![];
|
||||
let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64;
|
||||
let mut pts: Vec<Ridge> = vec![];
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
let step = len / (ridges + 1) as f64;
|
||||
for j in 0..=ridges {
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let mut gap_in = step;
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
Some(sp.segment(i - 1))
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(sp.segment(segs - 1))
|
||||
Some(segs - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gap_in = prev.map_or(0.0, gap);
|
||||
let n1 = normal_at(&c, 0.0);
|
||||
match prev {
|
||||
Some(pc) => {
|
||||
let s = n1 + normal_at(&pc, 1.0);
|
||||
Some(pi) => {
|
||||
let s = n1 + normal_at(&sp.segment(pi), 1.0);
|
||||
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
|
||||
}
|
||||
None => n1,
|
||||
@@ -255,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
normal_at(&c, t)
|
||||
};
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.eval(t) + nrm * (amp * sign));
|
||||
pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step });
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
@@ -263,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
if segs > 0 {
|
||||
let c = sp.segment(segs - 1);
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
|
||||
let nrm = normal_at(&c, 1.0);
|
||||
pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 });
|
||||
} else {
|
||||
pts.extend(sp.anchors.iter().map(|a| a.p));
|
||||
pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 }));
|
||||
}
|
||||
}
|
||||
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
|
||||
subs.push(if smooth {
|
||||
let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect();
|
||||
SubPath::new(anchors, sp.closed)
|
||||
} else {
|
||||
SubPath::polyline(&pts.iter().map(|r| r.p).collect::<Vec<_>>(), sp.closed)
|
||||
});
|
||||
}
|
||||
PathData::new(subs)
|
||||
}
|
||||
+188
-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,18 @@ 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};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
|
||||
Arc::new(Node::path(
|
||||
@@ -319,6 +336,77 @@ 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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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 +418,43 @@ 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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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 +462,61 @@ 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() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
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 });
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
||||
for (k, paint) in paints.into_iter().enumerate() {
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
|
||||
let mut g = Node::group(NodeId(10), vec![square]);
|
||||
let params = serde_json::json!({"moveH": 30, "copies": 2});
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
|
||||
assert_eq!(copies.len(), 3);
|
||||
for (i, c) in copies.iter().enumerate() {
|
||||
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
|
||||
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
|
||||
match c.appearance.fill_paint() {
|
||||
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
|
||||
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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_gradient_copies, 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")),
|
||||
|
||||
+300
-24
@@ -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,36 @@ 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` a path or compound path with a visible Transform effect that makes copies and a gradient
|
||||
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
|
||||
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
|
||||
pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
|
||||
&& geometry_effects(n)
|
||||
.iter()
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
|
||||
}
|
||||
|
||||
/// 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 +70,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 +120,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
|
||||
@@ -83,10 +138,20 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
a.transform(*xf, false);
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
_ if has_gradient_copies(n) => {
|
||||
let mut one = n.clone();
|
||||
one.opacity = 1.0;
|
||||
one.blend = Default::default();
|
||||
one.isolate = false;
|
||||
one.extras_mut().mask = None;
|
||||
one.appearance.effects.clear();
|
||||
Node::group(n.id, vec![Arc::new(one)])
|
||||
}
|
||||
_ 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,33 +162,51 @@ 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) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
@@ -154,11 +237,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,18 +282,209 @@ 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);
|
||||
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed));
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// A gradient paints each copy by itself, not the one piece the copies make together.
|
||||
if let Some((m, copies)) = transform
|
||||
&& copies > 0
|
||||
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
|
||||
{
|
||||
copy_with_gradients(leaf, &path, m, copies);
|
||||
return;
|
||||
}
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if let Some((m, copies)) = transform {
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
|
||||
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > crate::distort::MAX_COPY_ANCHORS {
|
||||
break;
|
||||
}
|
||||
xf *= m;
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, vectorcraft_doc::Scaling::default());
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
}
|
||||
|
||||
/// 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 +492,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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+361
-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))
|
||||
}
|
||||
@@ -224,12 +244,186 @@ fn stacked_transform_copies_stay_within_the_anchor_budget() {
|
||||
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());
|
||||
@@ -316,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and
|
||||
/// valley is a smooth point whose handles run parallel to the line, and the curve between them
|
||||
/// follows a cosine, even where the ridges are much taller than they are apart.
|
||||
#[test]
|
||||
fn zigzag_smooth_is_a_wave() {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
for size in [3.0, 30.0] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line);
|
||||
let sp = &out.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 11);
|
||||
for a in &sp.anchors {
|
||||
assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}");
|
||||
}
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
for k in 0..=20 {
|
||||
let q = c.eval(k as f64 / 20.0);
|
||||
let want = -size * (std::f64::consts::PI * q.x / 10.0).cos();
|
||||
assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
@@ -378,6 +597,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();
|
||||
@@ -400,6 +631,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());
|
||||
@@ -436,6 +727,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}))];
|
||||
@@ -462,11 +806,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:?}"),
|
||||
}
|
||||
@@ -475,6 +820,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::*;
|
||||
@@ -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>> {
|
||||
|
||||
@@ -80,21 +80,22 @@ 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; the first of
|
||||
/// [`Self::artboards`] when there are any): 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>>,
|
||||
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
|
||||
/// own resources as they are now.
|
||||
pub source_doc: Option<u64>,
|
||||
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
|
||||
/// adds a copy of it with the art.
|
||||
pub artboard: Option<vectorcraft_doc::Artboard>,
|
||||
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
|
||||
/// Pasting adds a copy of each with the art.
|
||||
pub artboards: Vec<vectorcraft_doc::Artboard>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboard.is_none()
|
||||
self.nodes.is_empty() && self.artboards.is_empty()
|
||||
}
|
||||
|
||||
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
||||
@@ -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,10 +39,19 @@ 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 of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
cmd!(
|
||||
"color.removeProfile",
|
||||
"Remove Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
|
||||
always,
|
||||
remove_profile
|
||||
),
|
||||
cmd!(
|
||||
"edit.assignProfile",
|
||||
"Assign Profile…",
|
||||
@@ -84,7 +93,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
|
||||
),
|
||||
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
Ok(json!({"name": info.name, "kind": info.kind}))
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
}
|
||||
|
||||
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
|
||||
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.removeProfile";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
}
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == name || working.cmyk == name {
|
||||
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
|
||||
}
|
||||
if !cms::unregister_icc(&info.name) {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -95,6 +95,12 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
|
||||
|
||||
/// Insert a new object looking like `look` (drawing modes apply); select it.
|
||||
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
|
||||
insert_look(s, label, kind, look, name, true)
|
||||
}
|
||||
|
||||
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
|
||||
/// stays as it was.
|
||||
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let mode = s.draw_mode;
|
||||
let inside = s.draw_inside;
|
||||
@@ -137,7 +143,9 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
}
|
||||
}
|
||||
sel.set([id]);
|
||||
if select {
|
||||
sel.set([id]);
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
@@ -504,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
let made = super::typecmd::with_bounds(s, id, made);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -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
|
||||
),
|
||||
@@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Most node ids `document.inspect {ids}` takes.
|
||||
const MAX_INSPECT_IDS: usize = 10_000;
|
||||
|
||||
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.inspect";
|
||||
let opts = slice_opts(p, C)?;
|
||||
let ids = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
|
||||
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
|
||||
};
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(_) => return Err(bad(C, "`kind` must be a string")),
|
||||
};
|
||||
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
|
||||
Ok(inspect::document_scoped(s, opts, &scope))
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
@@ -324,9 +344,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).
|
||||
@@ -336,23 +369,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" {
|
||||
@@ -468,16 +515,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent()?;
|
||||
// A copied artboard comes back with its art: right of the last artboard, or where it was
|
||||
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
|
||||
let board = clip
|
||||
.artboard
|
||||
.as_ref()
|
||||
// Copied artboards come back with their art: together right of the last artboard, as they lay,
|
||||
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
|
||||
// art.
|
||||
let boards = clip
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.filter(|_| mode != PasteMode::AllArtboards)
|
||||
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -488,7 +538,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
@@ -501,9 +551,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
|
||||
let artboards: Vec<usize> = boards
|
||||
.into_iter()
|
||||
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
|
||||
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
|
||||
.collect();
|
||||
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
|
||||
// Objects pasted into each parent so far (keeps their order in front and back pastes).
|
||||
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
|
||||
@@ -538,15 +592,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
Ok((new_ids, imported, artboard))
|
||||
Ok((new_ids, imported, artboards))
|
||||
})?;
|
||||
// The Artboard tool takes the pasted artboard.
|
||||
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
|
||||
s.set_tool_option("active", &json!(i));
|
||||
// The Artboard tool selects the pasted artboards, the first one active.
|
||||
if !artboards.is_empty() && s.tool_id() == "artboard" {
|
||||
s.set_tool_option("selected", &json!(artboards));
|
||||
}
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"artboard": artboard,
|
||||
"artboard": artboards.first(),
|
||||
"artboards": artboards,
|
||||
"added": imported.added,
|
||||
"merged": imported.merged,
|
||||
"renamed": imported.renamed,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -138,10 +138,14 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
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 +301,34 @@ 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)?;
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.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 +345,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 +367,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 +457,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;
|
||||
@@ -459,10 +509,11 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects: through their outlines.
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient): through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -647,10 +698,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 +834,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 +959,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();
|
||||
@@ -789,4 +1001,27 @@ mod tests {
|
||||
assert_eq!(ch.len(), 1);
|
||||
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let (mut s, id) = session_with_rect();
|
||||
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 = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
s.edit("gradient", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.set_fill(ramp);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let mut boxes = vec![];
|
||||
node(&s, id).walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
|
||||
boxes.push(n.geometric_bounds().unwrap().width());
|
||||
}
|
||||
});
|
||||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
|
||||
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -108,6 +108,8 @@ struct Expandable {
|
||||
placed: bool,
|
||||
/// Repeats (Object expands them into groups of their instances).
|
||||
repeats: bool,
|
||||
/// Intertwine groups (Object expands them into groups of what they draw).
|
||||
intertwines: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
@@ -123,6 +125,7 @@ impl Expandable {
|
||||
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
@@ -134,7 +137,7 @@ impl Expandable {
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
|
||||
}
|
||||
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
|
||||
// over the others, expand below.
|
||||
if object && e.intertwines {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
|
||||
if object && e.blends {
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -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,554 @@
|
||||
//! `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;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::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> {
|
||||
use super::distinct_names;
|
||||
use super::screens::file_part;
|
||||
|
||||
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();
|
||||
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
|
||||
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
|
||||
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
|
||||
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, stem) in names.iter().zip(&stems) {
|
||||
match process_one_dataset(s, &run, name, 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, stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": 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:?}");
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A new document with an empty data set for each of `names`.
|
||||
fn with_data_sets(names: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
for name in names {
|
||||
s.execute("dataset.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_data_set_files_inside_the_destination_folder() {
|
||||
let root = tmp("set-names-root");
|
||||
let dest = root.join("out");
|
||||
// The folder that `a/../../x` would pass through on its way out of `out`.
|
||||
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
|
||||
let mut s = with_data_sets(&["a/../../x", "../y"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
|
||||
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_separators() {
|
||||
let dest = tmp("set-names-sep");
|
||||
let exports = tmp("set-names-sep-export");
|
||||
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
|
||||
// Override Export points each data set's export at the destination folder under the same name.
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": exports.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
|
||||
}
|
||||
|
||||
#[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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 615 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user