Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
079f9a8b9a | ||
|
|
8e40794153 | ||
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a26aa5b203 | ||
|
|
7be80776f2 | ||
|
|
a9c31fcbe7 | ||
|
|
89ff09e9fd | ||
|
|
71efade26b | ||
|
|
6a62dc58ba | ||
|
|
c462057460 | ||
|
|
539dcb84b7 | ||
|
|
d5e21cc735 | ||
|
|
bc688bd1b5 | ||
|
|
11799fa0cc | ||
|
|
b6649a3fad | ||
|
|
49dc3b90be | ||
|
|
c256818665 | ||
|
|
a20e680f62 | ||
|
|
68a14472c6 | ||
|
|
e2227849a1 | ||
|
|
3c096da84d | ||
|
|
47104f5f4c | ||
|
|
ea5a04504a | ||
|
|
d6eb526186 | ||
|
|
ce3683a508 | ||
|
|
333a487dab | ||
|
|
b8f77aefb6 | ||
|
|
40bdfd3aca | ||
|
|
dd595cf2e8 | ||
|
|
614606cc48 | ||
|
|
13f65ce6d4 | ||
|
|
308c096889 | ||
|
|
ba2ee7a174 | ||
|
|
b743d08f11 | ||
|
|
ab2daad763 | ||
|
|
80323a7a4e | ||
|
|
5f59332ec8 | ||
|
|
926cdc80a5 | ||
|
|
b55e3ad70a | ||
|
|
48c96b504d | ||
|
|
2e134e56be | ||
|
|
a01e045905 | ||
|
|
eac383d5af | ||
|
|
94bc59b068 | ||
|
|
c6ac2bf3c5 | ||
|
|
198725ed38 | ||
|
|
7ce27caf88 | ||
|
|
c91ca3634f | ||
|
|
24f7d5fba5 | ||
|
|
cc4992172a | ||
|
|
9dc05ac980 | ||
|
|
339eb78a9b | ||
|
|
e01077a674 | ||
|
|
6313c8cfae | ||
|
|
f9a89a3211 | ||
|
|
fbcadf317c | ||
|
|
64891c1a2e | ||
|
|
c60cb4a9c2 | ||
|
|
83faf568b0 | ||
|
|
ee1e95d1c7 | ||
|
|
e960baa131 | ||
|
|
0e5e093dd9 | ||
|
|
6572636d62 | ||
|
|
cf7a43d786 | ||
|
|
f4181890bd | ||
|
|
116ff3eee7 | ||
|
|
902c3ae8c1 | ||
|
|
2507ed9dd9 | ||
|
|
e80b7e383b | ||
|
|
597b651bb6 | ||
|
|
945f8db359 | ||
|
|
e50f02e638 | ||
|
|
aeaece9728 | ||
|
|
c6c2c7d0fe | ||
|
|
61cd4ed6e6 | ||
|
|
0e1a5b3e45 | ||
|
|
b05e575eb8 | ||
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
10fafed6c4 | ||
|
|
aafd565b99 | ||
|
|
571881a830 | ||
|
|
347369187f | ||
|
|
d899a63286 | ||
|
|
f95b032c82 | ||
|
|
ce9f3aa7c6 | ||
|
|
ef3fa1689f | ||
|
|
68352dab02 | ||
|
|
51a9b899c1 | ||
|
|
81e7b915af | ||
|
|
fcc9414480 | ||
|
|
377204f90e | ||
|
|
0306d51592 | ||
|
|
ebc6d1af7b | ||
|
|
cdb734aede | ||
|
|
2f743f155f | ||
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 | ||
|
|
80eb5844c7 | ||
|
|
82be706b94 | ||
|
|
5af667182a | ||
|
|
700b6786c4 | ||
|
|
77159bb08e | ||
|
|
047a3bfc6f | ||
|
|
856e36c694 | ||
|
|
721d1118f3 | ||
|
|
3491b99321 | ||
|
|
546a65f77a | ||
|
|
77708ad8de | ||
|
|
129c9ca61e | ||
|
|
ceb6defcf3 | ||
|
|
ab6a668aa9 | ||
|
|
8dc2fd077f | ||
|
|
66fa0a998a | ||
|
|
b045390311 | ||
|
|
7a39cb7b89 | ||
|
|
15ab25426e | ||
|
|
899a82d31d | ||
|
|
7bca89dd15 | ||
|
|
7f5344a60c | ||
|
|
691a54e7fd | ||
|
|
1733fa5fdb | ||
|
|
7715dcd285 | ||
|
|
caba979b01 | ||
|
|
025315f4bf | ||
|
|
4c6ac00e72 | ||
|
|
44b2b984ed | ||
|
|
2e2bb33909 | ||
|
|
5bb273487e | ||
|
|
695c541f0d | ||
|
|
254251f23a | ||
|
|
99e3aaa798 | ||
|
|
0889f13cd8 | ||
|
|
565b75b91d | ||
|
|
3280586f4c | ||
|
|
8255d606ae | ||
|
|
5a13626faa | ||
|
|
2967c3a9ba | ||
|
|
04fa49413c | ||
|
|
cd99714b42 | ||
|
|
e1225f4c24 | ||
|
|
98a3e15ec3 | ||
|
|
55d7df36e4 | ||
|
|
fe2259536d | ||
|
|
13db157d36 | ||
|
|
bd91cf9123 | ||
|
|
5061404ec6 | ||
|
|
e9abc62f1a | ||
|
|
fc0cb255a4 | ||
|
|
aa08c5bef7 | ||
|
|
e3b079de8c | ||
|
|
85e7acf138 | ||
|
|
e774f15537 | ||
|
|
0f0dbbf9d5 | ||
|
|
7ec936a0c1 | ||
|
|
b7c032df43 | ||
|
|
117aa0b5a3 | ||
|
|
47ef223a02 | ||
|
|
8752561132 | ||
|
|
3d35b8518f | ||
|
|
a5c2d6b682 | ||
|
|
781f797b00 | ||
|
|
c5985b3f47 | ||
|
|
5157dc87b8 | ||
|
|
6210f2f15d | ||
|
|
a0847e8a25 | ||
|
|
7df7cf568a | ||
|
|
b062070642 | ||
|
|
08c70dc35d | ||
|
|
4a59a171e2 | ||
|
|
8974957890 | ||
|
|
c3c4185cef | ||
|
|
3d850a2864 | ||
|
|
aa5b544fca | ||
|
|
92cadd6083 | ||
|
|
4950cc17cf | ||
|
|
c6e69fad33 | ||
|
|
67b2213957 | ||
|
|
5a93e78ad8 | ||
|
|
aafe381eab | ||
|
|
42be928367 | ||
|
|
3bb1431422 | ||
|
|
4b5d776a73 | ||
|
|
fe6d6640fb | ||
|
|
6994f248b2 | ||
|
|
f29e139199 | ||
|
|
ecf245b7c9 | ||
|
|
fe7da442ca | ||
|
|
37c82d082e | ||
|
|
5d21826fa8 | ||
|
|
a05e251622 | ||
|
|
28697019b7 | ||
|
|
08108f693f | ||
|
|
32f73e9cce | ||
|
|
2c02502f43 | ||
|
|
e35f75f733 | ||
|
|
3f0bce6a88 | ||
|
|
a4d93b1462 | ||
|
|
719e648de1 | ||
|
|
945dcbae1d | ||
|
|
86dbc42d42 | ||
|
|
9002f86f73 | ||
|
|
fd99375b1b | ||
|
|
f24198ec7e | ||
|
|
8c8ac37300 | ||
|
|
5e901c9c17 | ||
|
|
b71e843c96 | ||
|
|
2d740acbd1 | ||
|
|
242e8a5fd5 | ||
|
|
d0aeeb0076 | ||
|
|
39ac89fc26 | ||
|
|
11740c9731 | ||
|
|
c4b87e36b2 | ||
|
|
f12428264c | ||
|
|
49e8413ebe | ||
|
|
285d94a3e6 | ||
|
|
9920156bb6 | ||
|
|
a844503e4c | ||
|
|
1c46c4c654 | ||
|
|
d396bc23af | ||
|
|
9106e344f8 | ||
|
|
52d6b9a2ba | ||
|
|
2076f82b47 | ||
|
|
a52c030963 | ||
|
|
25a194cc00 | ||
|
|
831d916d3d | ||
|
|
3d890203dd | ||
|
|
679191b4ca | ||
|
|
811ad1efd8 | ||
|
|
cccdf96cc1 | ||
|
|
7228275f1c | ||
|
|
649be860e2 | ||
|
|
4f01bc13b2 | ||
|
|
9c707b37fc | ||
|
|
6992c843b4 | ||
|
|
6780ffae60 | ||
|
|
1499834de7 | ||
|
|
8adc629055 | ||
|
|
871474f1a9 | ||
|
|
b4c39b8cb9 | ||
|
|
8939caa284 | ||
|
|
dd69402f8b | ||
|
|
96604acffb | ||
|
|
d3d6cce917 | ||
|
|
72be324732 | ||
|
|
638edec175 | ||
|
|
03ea4079f9 | ||
|
|
e94dfed775 | ||
|
|
2f75b6cd29 | ||
|
|
69dc539db5 | ||
|
|
30550f038a | ||
|
|
535220cf34 | ||
|
|
65dd757d59 | ||
|
|
088ffc844b | ||
|
|
885b7acac1 | ||
|
|
63bf2c4598 | ||
|
|
d0d934bfda | ||
|
|
258337cf7c | ||
|
|
d4c4361b4a | ||
|
|
0aba6ef6e4 | ||
|
|
36a6a8ae49 | ||
|
|
2b15d3234f | ||
|
|
980372f6d0 | ||
|
|
a867c1e020 | ||
|
|
7c0a253b50 | ||
|
|
7a861ae064 | ||
|
|
466f863d00 | ||
|
|
a1deca3d13 | ||
|
|
cd8973a520 | ||
|
|
79581c7ff5 | ||
|
|
8961824876 | ||
|
|
87df4a0648 | ||
|
|
a09cbe58c8 | ||
|
|
0e4d3f6871 | ||
|
|
d7e9f2a47a | ||
|
|
1f922fcbbc | ||
|
|
1b9b37454b | ||
|
|
9779d21962 | ||
|
|
a825aed763 | ||
|
|
4e72f8c0ec | ||
|
|
2b48392c36 | ||
|
|
491b79dd9c | ||
|
|
0428e56342 | ||
|
|
6752b22783 | ||
|
|
9f8b7334f2 | ||
|
|
7c748a5e3c | ||
|
|
ffce1cf686 | ||
|
|
f1f7b1f945 | ||
|
|
a5367b0846 | ||
|
|
5b69862a08 | ||
|
|
51f6817a0b | ||
|
|
ced1527903 | ||
|
|
c06c13f577 | ||
|
|
ed4eceb837 | ||
|
|
0f161926cf | ||
|
|
39a7af7eba | ||
|
|
df4b84048e | ||
|
|
db7164a228 | ||
|
|
6017846ef6 | ||
|
|
c816f16bad | ||
|
|
88b9eee4ad | ||
|
|
2bab433c92 | ||
|
|
1e09016b80 | ||
|
|
46dc67ca50 | ||
|
|
6757c7f9f3 | ||
|
|
c6450f6f70 | ||
|
|
b578c32f4d | ||
|
|
f2fc8782c7 | ||
|
|
d01b2fbd8a | ||
|
|
961ca75a28 | ||
|
|
ab1c6b0516 | ||
|
|
2e6585d7ea | ||
|
|
bf4614669e | ||
|
|
d8d1d472cf | ||
|
|
975067b44a | ||
|
|
b379f02527 | ||
|
|
3d87b71ed4 | ||
|
|
766b612070 | ||
|
|
0312f1ea55 | ||
|
|
e2e36f221b | ||
|
|
4c4850e745 | ||
|
|
90e022b0c0 | ||
|
|
8b50a2604d | ||
|
|
819a0cb9b4 | ||
|
|
33023c5a2b | ||
|
|
fa3dcab411 | ||
|
|
eacc83e05b | ||
|
|
c96bf40d18 | ||
|
|
902d95173e | ||
|
|
8ab7237443 | ||
|
|
522f05c540 | ||
|
|
b0e39d0912 | ||
|
|
8fb1733f05 | ||
|
|
8046b9be84 | ||
|
|
a51083733e | ||
|
|
66e92bbd4c | ||
|
|
fbc7549c31 | ||
|
|
271b0c6c25 | ||
|
|
d767fa22e0 | ||
|
|
8d8ca11e50 | ||
|
|
2925e7f9b5 | ||
|
|
171de62fde | ||
|
|
db0a7ffd8b | ||
|
|
65cdc45ed9 | ||
|
|
7aee3bd487 | ||
|
|
303fc3cbca | ||
|
|
0d75df4da8 | ||
|
|
3e8c8d6264 | ||
|
|
a17cc428d1 | ||
|
|
448437fb76 | ||
|
|
cfed319af4 | ||
|
|
ffc60d5ff8 | ||
|
|
e967c31be6 | ||
|
|
7051a65c65 | ||
|
|
7b30dd787b | ||
|
|
851ed15104 | ||
|
|
53f42797f3 | ||
|
|
da18cf4234 | ||
|
|
ac77b52ad3 | ||
|
|
ba4ed2ff93 | ||
|
|
db9d937e1a | ||
|
|
489bb84755 | ||
|
|
70e2726321 | ||
|
|
9fae8c56ec | ||
|
|
515ce2ac94 | ||
|
|
ba6597b7b5 | ||
|
|
0968e34e7e | ||
|
|
a560f74e17 | ||
|
|
9aa29aa0fd | ||
|
|
0ffaa427d1 | ||
|
|
4b956422c9 | ||
|
|
2724263d00 | ||
|
|
2e5ec7b251 | ||
|
|
0c65fe77f3 | ||
|
|
94f0f1b0d2 | ||
|
|
6a446b4fa3 | ||
|
|
a908ba0190 | ||
|
|
cc03a47b7d | ||
|
|
f346727dd8 | ||
|
|
ee2aa96dcd | ||
|
|
e0895fd4ba | ||
|
|
8765d335ec | ||
|
|
e204341996 | ||
|
|
e8af1f6576 | ||
|
|
0e5425544a | ||
|
|
d6ebecee39 | ||
|
|
950a941003 | ||
|
|
157724279f | ||
|
|
bba9d2ceb8 | ||
|
|
74b5a904b7 | ||
|
|
370c6e2fc0 | ||
|
|
4cccbc2c6e | ||
|
|
950984b3db | ||
|
|
a31f212021 | ||
|
|
76b7d960f2 | ||
|
|
20e0935116 | ||
|
|
26246980e3 | ||
|
|
6afcacbd91 | ||
|
|
d527349f5a | ||
|
|
25df58e722 | ||
|
|
96aba71759 | ||
|
|
b75ed0877d | ||
|
|
32252c68ea | ||
|
|
1982ecf98d | ||
|
|
4a3859c69b | ||
|
|
2df7edd8c9 | ||
|
|
3b99df3b9a | ||
|
|
0e31fc92a2 | ||
|
|
a40bce615a | ||
|
|
4304a7c0b1 | ||
|
|
b0a8d78926 | ||
|
|
5c58ebcb32 | ||
|
|
bc17c1e635 | ||
|
|
39ce7b0e01 | ||
|
|
8ac84dde20 | ||
|
|
0744347065 | ||
|
|
8d0fae610d | ||
|
|
7fb07bb3db | ||
|
|
1a5f7f6441 | ||
|
|
3e323bfa2a | ||
|
|
0f241aac9f | ||
|
|
c4236e1bb2 | ||
|
|
a0b694dcd8 | ||
|
|
5750786e63 | ||
|
|
b9fa2d27fd | ||
|
|
bd7ed58376 | ||
|
|
025d758abe | ||
|
|
ccdb1b30f6 | ||
|
|
5a0e6be852 | ||
|
|
0038e00382 | ||
|
|
be648089be | ||
|
|
81582c8d4f | ||
|
|
96a1a02436 | ||
|
|
d8eff17b9f | ||
|
|
b8793eeaef | ||
|
|
37acaebf95 | ||
|
|
faf006e135 | ||
|
|
b79262d4f4 | ||
|
|
d9740931a5 | ||
|
|
9537eca5f7 | ||
|
|
6ee994239d | ||
|
|
fcc0f4ca0f | ||
|
|
fca068a0a7 | ||
|
|
0c950aae0e | ||
|
|
1aac138b4f | ||
|
|
321d8e78cf | ||
|
|
65f0f34cd3 | ||
|
|
5afc78a100 | ||
|
|
f3397fadc6 | ||
|
|
b32284aeae | ||
|
|
ea61d34a93 | ||
|
|
c1df6f0621 | ||
|
|
4591c85845 | ||
|
|
9fcefde3f9 | ||
|
|
be7006ccf5 | ||
|
|
bc59c53b5a | ||
|
|
705976fd96 | ||
|
|
4757dd265c | ||
|
|
3b2f46137a | ||
|
|
799b507564 | ||
|
|
1603729ec3 | ||
|
|
03571affbd | ||
|
|
cbdef4ea1f | ||
|
|
874a287958 | ||
|
|
2b07aa2ac9 | ||
|
|
c1dc860f1d | ||
|
|
fdad11a99c | ||
|
|
3e73ad8581 | ||
|
|
c73aa380e1 | ||
|
|
953024cc35 | ||
|
|
1dc158cafe | ||
|
|
936721fbf5 | ||
|
|
3675156c4e | ||
|
|
7227e295ea | ||
|
|
b2ececc7b4 | ||
|
|
e94748dc17 | ||
|
|
b4ba4ecf0a | ||
|
|
b768b4a53b | ||
|
|
a2adf729a7 | ||
|
|
6a9b658443 | ||
|
|
303689c8f4 | ||
|
|
2e6662c93b | ||
|
|
512e96e7c8 | ||
|
|
3c856caa65 | ||
|
|
8dc6396971 | ||
|
|
b36fd67bf4 | ||
|
|
d8b942c43d | ||
|
|
1c18851728 | ||
|
|
f75d7628c7 | ||
|
|
cc514957f5 | ||
|
|
ed308d61f7 | ||
|
|
2877407111 | ||
|
|
0d145986e8 | ||
|
|
09778f236c | ||
|
|
1ea13b54d3 | ||
|
|
64d366e2ae | ||
|
|
323b178ea7 | ||
|
|
3a79e6d922 | ||
|
|
786f2c05d3 | ||
|
|
e7f6328188 | ||
|
|
8a837fd4b9 | ||
|
|
25c8f32fdd | ||
|
|
d3df1bcea2 | ||
|
|
e21ff60b6a | ||
|
|
45ff33e144 | ||
|
|
54427d7a92 | ||
|
|
a1a5c79b03 | ||
|
|
bcc30ded5a | ||
|
|
2edac7e317 | ||
|
|
1de1c6446f | ||
|
|
bfebf66404 | ||
|
|
6c25e54914 | ||
|
|
cb881b082f | ||
|
|
0c82a3f5c5 | ||
|
|
6808564486 | ||
|
|
edeb1cb548 | ||
|
|
ca709871f2 | ||
|
|
3981acdb06 | ||
|
|
43d7ac420d | ||
|
|
deaeef047c | ||
|
|
454c5f8620 | ||
|
|
42ad4540b7 | ||
|
|
0f169b5c5d | ||
|
|
818980a789 | ||
|
|
cb68229bda | ||
|
|
0358c4df95 | ||
|
|
0f1565aed2 | ||
|
|
abd77d58b0 | ||
|
|
4e60eabc00 | ||
|
|
8008e16f57 | ||
|
|
f48ba26380 | ||
|
|
039c0cbef7 | ||
|
|
b85ef3de5a | ||
|
|
22581f5b51 | ||
|
|
93640efb36 | ||
|
|
f363cdb4ee | ||
|
|
c95098d15a | ||
|
|
b1d41d156a | ||
|
|
6e0af835c6 | ||
|
|
bdf8f704af | ||
|
|
321ab52aea | ||
|
|
10ea4d5daf | ||
|
|
eb83efdb19 | ||
|
|
059f623410 | ||
|
|
fe258915e3 | ||
|
|
18d870c7df | ||
|
|
6bd27a05da | ||
|
|
96f1b6852a | ||
|
|
546559d8a1 | ||
|
|
0b547ec597 | ||
|
|
62569f8a9d | ||
|
|
721bb483c3 | ||
|
|
41f2af932c | ||
|
|
efc0ac31d9 | ||
|
|
1ae0c22c26 | ||
|
|
0af6734b70 | ||
|
|
ead8f72208 | ||
|
|
3326b1aaac | ||
|
|
6b93a3dded | ||
|
|
4be1e009ec | ||
|
|
a4696edbe8 | ||
|
|
cbf5dc99bb | ||
|
|
8164d7c3fb | ||
|
|
439f99e491 | ||
|
|
a1430cfc1e | ||
|
|
ef5beece81 | ||
|
|
ad82e6e9a9 | ||
|
|
c071dcd4d0 | ||
|
|
ff9af2628a | ||
|
|
6d2dc7cbbc | ||
|
|
f21161ce8c | ||
|
|
8a871d98a9 | ||
|
|
2ed962e7d0 | ||
|
|
cca1d11032 | ||
|
|
6ebffb9156 | ||
|
|
d3dc4a2af3 | ||
|
|
1b3e7110a3 | ||
|
|
8910f8fd1e | ||
|
|
d1d133bf09 | ||
|
|
e9e4b94ed0 | ||
|
|
984eb7f10c | ||
|
|
745d50f76e | ||
|
|
014ee0382f | ||
|
|
4283820a30 | ||
|
|
3786cfaf36 | ||
|
|
3692b9d233 | ||
|
|
7da0bfb1c6 | ||
|
|
4f685be57a | ||
|
|
b6b87ee9b6 | ||
|
|
5ae7bfb8ca | ||
|
|
23f19a861b | ||
|
|
09d7aaa7f4 | ||
|
|
aa1b592935 | ||
|
|
3082be6ecf | ||
|
|
543d09985d | ||
|
|
b117566fc2 | ||
|
|
6b159467a2 | ||
|
|
ce5fdfd373 | ||
|
|
cab5dffbcf | ||
|
|
152228d76d | ||
|
|
c20b7deec5 | ||
|
|
4ac7095601 | ||
|
|
1fd9b79a9d | ||
|
|
d272d32be3 | ||
|
|
81f86a9acc | ||
|
|
018bed29db | ||
|
|
61ddc1ce34 | ||
|
|
2f21fc3a66 | ||
|
|
00a0874d4d | ||
|
|
8f9d6ef2bd | ||
|
|
fb8c41d8e3 | ||
|
|
115255a7e4 | ||
|
|
29d43d2a84 | ||
|
|
879295dd1a | ||
|
|
7b410ecf2c | ||
|
|
7bc50901e4 | ||
|
|
5d9b97c3bd | ||
|
|
dd0bb0eefb | ||
|
|
45a7302601 | ||
|
|
4d503c0327 | ||
|
|
9cabeea68b | ||
|
|
b7969a6a5d | ||
|
|
0af38a31fd | ||
|
|
016fcbf34e | ||
|
|
d99853ed2a | ||
|
|
5fab94753c | ||
|
|
604c20ed90 | ||
|
|
e08ced735e | ||
|
|
b538b0a84a | ||
|
|
62681d2b80 | ||
|
|
4ddc422e51 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
## What and why
|
||||
|
||||
<!-- What does this change, and why? Link the issue if there is one. -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Tests added, and how you checked the behaviour (CLI, MCP, screenshots). -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `cargo xtask ci` passes (fmt, clippy, tests, assets, brands, layers, wasm).
|
||||
- [ ] No panics in shipped code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or
|
||||
`unimplemented!` outside tests, and no unchecked indexing on data from files, parameters or
|
||||
messages. Errors are returned as `Result` and shown to the user
|
||||
([why and how](https://github.com/storytold/vectorcraft/blob/main/docs/development.md#robustness-vectorcraft-never-crashes)).
|
||||
- [ ] Clean-room: nothing copied from Adobe products or GPL/AGPL code ([AGENTS.md](https://github.com/storytold/vectorcraft/blob/main/AGENTS.md)).
|
||||
- [ ] Every new asset has a row in `ASSETS.md`.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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).
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
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@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
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@v4
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs 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"
|
||||
|
||||
@@ -70,6 +70,13 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,6 +97,8 @@ jobs:
|
||||
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@v4
|
||||
with:
|
||||
@@ -112,11 +121,21 @@ jobs:
|
||||
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@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -141,6 +160,8 @@ jobs:
|
||||
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@v4
|
||||
with:
|
||||
@@ -167,6 +188,13 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -184,6 +212,9 @@ jobs:
|
||||
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: |
|
||||
@@ -201,6 +232,56 @@ jobs:
|
||||
path: dist/release/*
|
||||
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 as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@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
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -212,6 +293,13 @@ jobs:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -227,6 +315,9 @@ jobs:
|
||||
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@v4
|
||||
with:
|
||||
@@ -236,7 +327,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
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
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 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@v4
|
||||
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@v4
|
||||
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'
|
||||
@@ -5,3 +5,5 @@ plan/
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
|
||||
/craft-fonts
|
||||
@@ -5,8 +5,9 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
@@ -21,13 +22,24 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
|
||||
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
|
||||
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
|
||||
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
|
||||
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
|
||||
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
|
||||
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
|
||||
- `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.
|
||||
- **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`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
|
||||
@@ -39,4 +51,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
Every non-code asset in this repository (icons, images, fonts, example art, colour profiles, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed.
|
||||
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
|
||||
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
|
||||
|
||||
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
|
||||
|
||||
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `assets/app-icon/LICENSE.txt` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/app-icon/README.md` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.vectorcraft.png` | Project owner (ArtCraft team) | Original artwork drawn in ArtCraft for VectorCraft, vectorised and rendered by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon (engraved dragon) |
|
||||
@@ -64,12 +68,12 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/blend.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/brush.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chart-column.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-down.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevrons-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevrons-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-down.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevron-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevrons-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/chevrons-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/clipboard-paste.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/cloud.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/combine.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
@@ -89,6 +93,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -97,12 +103,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-cap-butt.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cap-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cap-square.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-center-hide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `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-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 | |
|
||||
| `assets/icons/dc-dist-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dist-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -125,6 +135,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-stroke-across.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-stroke-along.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-stroke-within.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-gradient.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-graphic-styles.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grid-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -134,6 +147,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -141,6 +155,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -173,6 +188,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -180,6 +196,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-reverse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rotate-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rounded-rect.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rule-evenodd.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rule-nonzero.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-scallop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-screen-mode.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -221,26 +239,26 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/history.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/house.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/image.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/info.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/info.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/lasso.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/layers.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/layout-grid.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/library.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/link-2-off.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/external-link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → app page link |
|
||||
| `assets/icons/link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/external-link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | Help → app page link |
|
||||
| `assets/icons/git-branch.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → GitHub link |
|
||||
| `assets/icons/globe.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → website link |
|
||||
| `assets/icons/message-circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Discord button and Help → Discord link |
|
||||
| `assets/icons/lock-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/lock.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/lock.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/map.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/maximize-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/menu.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/minus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/monitor.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/minus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/monitor.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/move-diagonal-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/move.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/move.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/paint-bucket.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/paintbrush.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/palette.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
@@ -249,7 +267,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/pilcrow.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/pin.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/pipette.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/plus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/plus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/printer.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/redo-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/rotate-3d.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
@@ -259,7 +277,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/save.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/scaling.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/scissors.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/search.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/search.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/settings.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/shapes.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/share-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
@@ -269,7 +287,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/spline.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/spray-can.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/square-dashed.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/square.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/square.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/squares-exclude.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/squares-intersect.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/squares-subtract.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
@@ -280,15 +298,15 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/swatch-book.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/text-cursor-input.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/tornado.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/trash-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/triangle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/type.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/trash-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/triangle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/type.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/undo-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/ungroup.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/wand-sparkles.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/waves.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
|
||||
| `docs/images/dusk-poster.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/dusk-poster.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `examples/dusk-poster.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/dusk-poster.svg` | VectorCraft contributors | SVG export of the above | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -5,8 +5,9 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
@@ -21,13 +22,24 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
|
||||
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
|
||||
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
|
||||
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
|
||||
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
|
||||
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
|
||||
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
|
||||
- `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.
|
||||
- **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`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
|
||||
@@ -39,4 +51,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
@@ -121,6 +121,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -458,6 +469,15 @@ version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -611,6 +631,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -714,6 +744,15 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -763,6 +802,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -775,6 +824,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1172,6 +1231,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1309,6 +1374,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1370,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1386,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1534,6 +1609,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1542,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1551,7 +1635,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1802,6 +1886,15 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -1885,6 +1978,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-encoder"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -1975,6 +2074,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -2067,6 +2172,16 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2902,6 +3017,15 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psd"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
|
||||
dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -3364,6 +3488,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3543,6 +3678,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
@@ -3573,6 +3724,16 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -3886,6 +4047,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -4044,24 +4211,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-ui-egui",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4071,9 +4243,25 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4089,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4101,8 +4289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4114,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4122,12 +4311,16 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4135,24 +4328,47 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"vectorcraft-trace",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4164,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4176,11 +4392,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
@@ -4191,9 +4409,23 @@ dependencies = [
|
||||
"vectorcraft-tools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4205,31 +4437,58 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.1.0"
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"log",
|
||||
"proptest",
|
||||
"psd",
|
||||
"serde_json",
|
||||
"tiff",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -4239,19 +4498,25 @@ dependencies = [
|
||||
"vectorcraft-text",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
@@ -4262,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4279,20 +4544,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"subsetter",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4305,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4318,29 +4586,35 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4348,6 +4622,7 @@ dependencies = [
|
||||
"log",
|
||||
"rfd",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -4504,6 +4779,99 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.261.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "261.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -5320,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -29,6 +29,10 @@ vectorcraft-engine = { path = "crates/engine" }
|
||||
vectorcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
vectorcraft-mcp = { path = "crates/mcp" }
|
||||
vectorcraft-testkit = { path = "crates/testkit" }
|
||||
vectorcraft-cad = { path = "crates/cad" }
|
||||
vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -38,15 +42,26 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
proptest = "1"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
socket2 = "0.6"
|
||||
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
|
||||
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
|
||||
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
|
||||
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
|
||||
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
|
||||
# rendering / formats
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -55,6 +70,7 @@ eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
clipboard-win = "5.4"
|
||||
webbrowser = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
@@ -62,7 +78,14 @@ unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
|
||||
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,14 +1,36 @@
|
||||
VectorCraft
|
||||
Copyright the VectorCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
This product bundles third-party material under the following licences. Per-file attribution
|
||||
(author, source, licence) for every asset is in ASSETS.md. No Adobe icons, images or artwork are included.
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
|
||||
- VectorCraft icons (assets/icons/dc-*.svg) — original work, MIT OR Apache-2.0.
|
||||
Third-party material
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ASSETS.md.
|
||||
|
||||
- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License; the icons Lucide derived from Feather are also under the MIT License (Cole Bemis). See assets/icons/LICENSE-lucide.txt.
|
||||
- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
- egui's default fonts, compiled into the app through the epaint_default_fonts crate — Ubuntu Light
|
||||
(Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font
|
||||
(MIT). Their license texts ship with that crate.
|
||||
|
||||
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
|
||||
permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
|
||||
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
|
||||
|
||||
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
|
||||
based in part on the work of the Independent JPEG Group.
|
||||
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
|
||||
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
|
||||
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
</p>
|
||||
@@ -133,7 +133,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
- **Robust.** Exact curve booleans (no "cannot perform operation"), unlimited undo via structural
|
||||
sharing, and property-tested file round trips.
|
||||
- **Open.** A documented native format (`.vectorcraft`, JSON), first-class SVG, PDF (and
|
||||
PDF-compatible `.ai`) import and export, PNG/JPEG/WebP, and Export for Screens.
|
||||
PDF-compatible `.ai`) import and export, PNG/JPEG/WebP export, Export for Screens, and SVGZ,
|
||||
templates, GIF, TIFF and BMP on open.
|
||||
- **Agent-native.** Every menu item, tool gesture, panel and dialog can be driven over a JSON
|
||||
control channel and an **MCP server**, so Claude and other agents can draw, edit and export the
|
||||
way a person does.
|
||||
@@ -150,9 +151,13 @@ cargo run --release -p vectorcraft-cli -- run --in examples/ribbons.vectorcraft
|
||||
cargo run --release -p vectorcraft-cli -- bench examples/neon-drive.vectorcraft # render timing
|
||||
cargo xtask bundle # dist/VectorCraft.app (macOS)
|
||||
cd apps/vectorcraft-web && trunk build --release # web build → dist/web
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
|
||||
```
|
||||
|
||||
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
@@ -168,12 +173,32 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and
|
||||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
Agent and contributor rules (clean-room, the asset policy, quality gates) are in
|
||||
[`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
|
||||
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
|
||||
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
|
||||
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -217,11 +242,22 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
## License and credits
|
||||
|
||||
MIT OR Apache-2.0. Bundled fonts are OFL, Lucide icons are ISC, and all other icons and art are
|
||||
original. Per-asset attribution is in [`ASSETS.md`](ASSETS.md) (see also [`NOTICE`](NOTICE)).
|
||||
VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
|
||||
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
|
||||
(SIL Open Font License 1.1).
|
||||
|
||||
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
|
||||
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-02._
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -12,117 +12,208 @@ _Last updated: 2026-10-02._
|
||||
|---|---|
|
||||
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~65%** |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~50%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~330–500 h** one agent · **~85–140 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~440–670 h** one agent · **~115–190 h** with 4–6 agents |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
|
||||
|
||||
### Shipped so far
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
|
||||
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
|
||||
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
|
||||
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
|
||||
|
||||
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
|
||||
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
|
||||
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
|
||||
since 2026-10-01.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
|
||||
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
|
||||
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
|
||||
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, accessibility, packaging. 50–80 h.
|
||||
|
||||
### Where we're going
|
||||
|
||||
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
|
||||
so they split well across agents, and they move the "can't tell the difference" number most.
|
||||
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
|
||||
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
|
||||
budget it misses before 1.0.
|
||||
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
|
||||
|
||||
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
|
||||
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
|
||||
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
|
||||
|
||||
## Shipped so far
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife.
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Formats:**
|
||||
- `.vectorcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`).
|
||||
- PNG, JPEG and WebP export, Export for Screens, Place.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Tests:** ~700 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 20–30 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~440–670** |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
|
||||
## Parity estimate
|
||||
|
||||
_Method (2026-10-02):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
|
||||
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
|
||||
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
|
||||
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
|
||||
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
|
||||
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
|
||||
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
|
||||
are counted bottom-up.
|
||||
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
|
||||
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
|
||||
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 75% | freeform gradients, swatch libraries (original), Tile Edge Color | 10–15 |
|
||||
| Strokes, brushes, width profiles | 5 | 65% | brush options depth, brush libraries (generated in code) | 15–25 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 85% | Flatten Transparency, style libraries, mask view (Alt-click) | 6–10 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
|
||||
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish | 6–10 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 60% | EPS in/out, DXF/DWG, PSD placement, PDF raster effects/presets | 40–55 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 45% | Asset Export panel, slices, Save for Web | 12–18 |
|
||||
| Print, colour management, separations, flattener | 3 | 40% | Print dialog (platform), flattener presets, print presets | 20–30 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 75% | ~14 panels (Tabs, Links, Attributes, Asset Export, SVG Interactivity, Variables, CSS Properties…), Properties per context | 20–30 |
|
||||
| Libraries, Links, Package | 2 | 15% | Links panel, Package, local libraries | 10–15 |
|
||||
| **Feature parity** | **103** | **~65%** | | **~330–500** |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
| **Full parity** | | **~50%** | | **~440–670** |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
|
||||
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~85–140 h** to feature parity,
|
||||
**~115–190 h** to full parity.
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
|
||||
**~80–145 h** to full parity.
|
||||
|
||||
_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79
|
||||
tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 32 panels of ~45; 44 live
|
||||
effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests.
|
||||
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! 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] [--outline-text]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
@@ -14,11 +14,41 @@
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
||||
/// Broken pipe (os error 32)".
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
/// ripgrep does; any other write error is reported.
|
||||
fn stdout_failed(e: std::io::Error) -> ! {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
@@ -28,21 +58,24 @@ USAGE:
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text]
|
||||
Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png,
|
||||
.jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept; --outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
|
||||
vectorcraft-cli bench FILE [--size WxH] [--iters N]
|
||||
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
@@ -50,6 +83,12 @@ USAGE:
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
fn usage() -> String {
|
||||
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
@@ -61,14 +100,14 @@ fn main() -> ExitCode {
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -105,6 +144,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
// The binary owns the logger, not the library: installing one here keeps an embedder that
|
||||
// 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())
|
||||
@@ -113,18 +155,19 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?,
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
@@ -135,10 +178,10 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let r = h
|
||||
.call(
|
||||
"engine.execute",
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}),
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -154,7 +197,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
||||
let artboards: Vec<Value> =
|
||||
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
||||
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -244,7 +287,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
@@ -253,7 +296,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
outln!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
|
||||
@@ -23,9 +23,9 @@ impl Rng {
|
||||
}
|
||||
|
||||
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
||||
pub fn synthetic(n: usize) -> Document {
|
||||
pub fn synthetic(n: usize) -> Result<Document, String> {
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let l = d.layers[0].id;
|
||||
let l = d.layers.first().map(|l| l.id);
|
||||
let mut r = Rng(42);
|
||||
for i in 0..n {
|
||||
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
|
||||
@@ -43,9 +43,9 @@ pub fn synthetic(n: usize) -> Document {
|
||||
if i % 3 == 1 {
|
||||
node.opacity = 0.8;
|
||||
}
|
||||
d.insert(Some(l), usize::MAX, node).expect("insert");
|
||||
d.insert(l, usize::MAX, node).map_err(|e| format!("building the synthetic document: {e}"))?;
|
||||
}
|
||||
d
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
@@ -57,7 +57,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
v.get(v.len() / 2).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n);
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
let doc = synthetic(n)?;
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -123,30 +123,43 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
|
||||
let mut bytes = vec![];
|
||||
rows.push(("save .vectorcraft", median_ms(3, || bytes = vectorcraft_format::save_file(&doc)), 300.0));
|
||||
rows.push(("open .vectorcraft", median_ms(3, || drop(vectorcraft_format::load(&bytes).expect("load"))), 300.0));
|
||||
let mut failed = None;
|
||||
let open = median_ms(3, || {
|
||||
if let Err(e) = vectorcraft_format::load(&bytes) {
|
||||
failed = Some(format!("open .vectorcraft: {e}"));
|
||||
}
|
||||
});
|
||||
rows.push(("open .vectorcraft", open, 300.0));
|
||||
let svg_opts = vectorcraft_svg::ExportOptions::default();
|
||||
rows.push(("export SVG", median_ms(3, || drop(vectorcraft_svg::export(&doc, &svg_opts))), 500.0));
|
||||
|
||||
// Pathfinder Unite on 1,000 overlapping paths.
|
||||
let unite = median_ms(3, || {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(1000), None);
|
||||
s.execute("select.all", &json!({})).expect("select");
|
||||
s.execute("object.pathfinder.unite", &json!({})).expect("unite");
|
||||
let r = synthetic(1000).and_then(|d| {
|
||||
s.add_document(d, None);
|
||||
s.execute("select.all", &json!({})).and_then(|_| s.execute("object.pathfinder.unite", &json!({}))).map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(e) = r {
|
||||
failed = Some(format!("Pathfinder Unite: {e}"));
|
||||
}
|
||||
});
|
||||
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
|
||||
if let Some(e) = failed {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
outln!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
@@ -80,3 +82,17 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
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();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> String {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
/// `run` a new `width`×`height` document with a rectangle and export it to `out`.
|
||||
fn make(out: &str, width: u32, height: u32) {
|
||||
ok(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"file.new",
|
||||
"--params",
|
||||
&format!(r#"{{"width":{width},"height":{height},"artboards":2}}"#),
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":1,"y":1,"width":2,"height":1}"#,
|
||||
"--export",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
|
||||
fn info(file: &str) -> Value {
|
||||
serde_json::from_str(&ok(&["info", file])).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_pdf_to_svg_and_info_on_ai() {
|
||||
let pdf = tmp("in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let svg = tmp("out.svg");
|
||||
ok(&["convert", &pdf, &svg]);
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let ai = tmp("in.ai");
|
||||
std::fs::copy(&pdf, &ai).unwrap();
|
||||
let v = info(&ai);
|
||||
assert_eq!(v["artboards"].as_array().unwrap().len(), 2, "{v}");
|
||||
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
|
||||
// `run --in` takes it too.
|
||||
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
|
||||
// A PDF keeps every artboard unless --artboard / --range names some.
|
||||
let all = tmp("all.pdf");
|
||||
ok(&["convert", &ai, &all]);
|
||||
assert_eq!(info(&all)["artboards"].as_array().unwrap().len(), 2);
|
||||
let second = tmp("second.pdf");
|
||||
ok(&["convert", &ai, &second, "--range", "2"]);
|
||||
assert_eq!(info(&second)["artboards"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_open_at_their_pixel_size() {
|
||||
let webp = tmp("tiny.webp");
|
||||
make(&webp, 3, 2);
|
||||
let v = info(&webp);
|
||||
assert_eq!(v["artboards"][0]["rect"], serde_json::json!([0.0, 0.0, 3.0, 2.0]), "{v}");
|
||||
assert_eq!(v["kinds"]["Image"], 1, "{v}");
|
||||
let png = tmp("tiny.png");
|
||||
ok(&["convert", &webp, &png, "--scale", "2"]);
|
||||
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_readable_formats() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
@@ -16,6 +16,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -26,4 +27,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", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] }
|
||||
@@ -1,14 +1,23 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::RecoveryStore;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
|
||||
|
||||
const CANVAS_ID: &str = "vectorcraft_canvas";
|
||||
const LOADING_ID: &str = "vectorcraft_loading";
|
||||
|
||||
@@ -23,6 +32,7 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let drag = track_drag(&canvas);
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
@@ -38,8 +48,9 @@ pub fn start() {
|
||||
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let inbox: Inbox = Arc::default();
|
||||
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
let place_inbox: PlaceInbox = Arc::default();
|
||||
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone()));
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -56,29 +67,58 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
|
||||
/// get during a drag.
|
||||
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
|
||||
let pos = DragPos::default();
|
||||
let p = pos.clone();
|
||||
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
|
||||
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
|
||||
});
|
||||
for kind in ["dragover", "drop"] {
|
||||
if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) {
|
||||
log::error!("couldn't follow {kind} events: {e:?}");
|
||||
}
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
on_drag.forget();
|
||||
pos
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
|
||||
struct WebShell {
|
||||
app: VectorcraftApp,
|
||||
inbox: Inbox,
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
if !dropped.is_empty() {
|
||||
let at = self.drag.take();
|
||||
let z = ctx.zoom_factor();
|
||||
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
|
||||
for f in dropped {
|
||||
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
match target {
|
||||
DropTarget::Place { at, embed } => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
|
||||
}
|
||||
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
@@ -92,14 +132,35 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
Services {
|
||||
// File → Place…: the picked files go to the Place dialog.
|
||||
place_async: Some(Box::new(move || {
|
||||
let inbox = picked.clone();
|
||||
let ctx = place_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let Some(files) = dialog.pick_files().await else {
|
||||
return;
|
||||
};
|
||||
let mut arrived = vec![];
|
||||
for f in files {
|
||||
arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None });
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
place_inbox: Some(place_inbox),
|
||||
open_async: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
@@ -113,21 +174,61 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
recovery_store: Some(Arc::new(BrowserStore)),
|
||||
// File → Print: the browser's print dialog.
|
||||
print: Some(Box::new(BrowserPrint)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
struct BrowserStore;
|
||||
|
||||
const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
|
||||
|
||||
fn js_err(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
|
||||
fn local_storage() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
|
||||
}
|
||||
|
||||
impl RecoveryStore for BrowserStore {
|
||||
fn list(&self) -> Result<Vec<String>, String> {
|
||||
let s = local_storage()?;
|
||||
let n = s.length().map_err(js_err)?;
|
||||
Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
|
||||
}
|
||||
fn read(&self, name: &str) -> Result<Vec<u8>, String> {
|
||||
let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
|
||||
text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
local_storage()?
|
||||
.set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
|
||||
.map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
|
||||
}
|
||||
fn location(&self) -> String {
|
||||
"browser storage".into()
|
||||
}
|
||||
fn now(&self) -> Option<i64> {
|
||||
Some((js_sys::Date::now() / 1000.0) as i64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let url = blob_url(bytes, fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime))?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
@@ -144,13 +245,62 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("vectorcraft" | "drawcraft") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
/// An object URL of a blob of `bytes` with media type `mime` (revoke it when done).
|
||||
fn blob_url(bytes: &[u8], mime: &str) -> Result<String, String> {
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime);
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js_err)?;
|
||||
web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)
|
||||
}
|
||||
|
||||
/// File → Print in the browser: the job's PDF in a hidden frame, printed through the browser's
|
||||
/// print dialog (which picks the printer).
|
||||
struct BrowserPrint;
|
||||
|
||||
/// The frame holding the last print job (the next job replaces it).
|
||||
const PRINT_FRAME_ID: &str = "vectorcraft-print-frame";
|
||||
|
||||
impl PrintService for BrowserPrint {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
// Browsers don't tell pages about printers: their print dialog lists them.
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
|
||||
print_pdf(job.pdf)?;
|
||||
Ok(format!("Printing “{}”: pick the printer in the browser's print dialog", job.title))
|
||||
}
|
||||
}
|
||||
|
||||
fn print_pdf(pdf: &[u8]) -> Result<(), String> {
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
// The previous job's frame and its blob go first.
|
||||
if let Some(old) = document.get_element_by_id(PRINT_FRAME_ID) {
|
||||
if let Some(url) = old.get_attribute("src") {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
}
|
||||
old.remove();
|
||||
}
|
||||
let url = blob_url(pdf, "application/pdf")?;
|
||||
let frame: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js_err)?.dyn_into().map_err(|_| "not a frame")?;
|
||||
frame.set_id(PRINT_FRAME_ID);
|
||||
// Out of sight but laid out: browsers don't print a frame that isn't.
|
||||
let style = frame.style();
|
||||
for (k, v) in [("position", "fixed"), ("right", "0"), ("bottom", "0"), ("width", "0"), ("height", "0"), ("border", "0")] {
|
||||
style.set_property(k, v).map_err(js_err)?;
|
||||
}
|
||||
let loaded = frame.clone();
|
||||
let onload = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
if let Some(w) = loaded.content_window() {
|
||||
w.focus().ok();
|
||||
if let Err(e) = w.print() {
|
||||
log::error!("printing failed: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
frame.set_onload(Some(onload.unchecked_ref()));
|
||||
frame.set_src(&url);
|
||||
document.body().ok_or("no body")?.append_child(&frame).map_err(js_err)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,11 +19,19 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
//! cross-compile from macOS or Linux still links, unless `VECTORCRAFT_REQUIRE_WINRES=1` turns it into
|
||||
//! an error (for release builds).
|
||||
|
||||
fn main() {
|
||||
fn main() -> Result<(), String> {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../assets/app-icon/vectorcraft.ico");
|
||||
println!("cargo:rerun-if-env-changed=VECTORCRAFT_REQUIRE_WINRES");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/vectorcraft.ico")
|
||||
@@ -21,8 +21,9 @@ fn main() {
|
||||
.set("InternalName", "vectorcraft");
|
||||
if let Err(e) = res.compile() {
|
||||
if std::env::var_os("VECTORCRAFT_REQUIRE_WINRES").is_some() {
|
||||
panic!("embedding Windows resources failed: {e}");
|
||||
return Err(format!("embedding Windows resources failed: {e}"));
|
||||
}
|
||||
println!("cargo:warning=vectorcraft.exe built without icon/version resources: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! The system clipboard with every format Copy offers and Paste reads
|
||||
//! (`Services::system_clipboard`).
|
||||
//!
|
||||
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
|
||||
//! Paste reads each of them.
|
||||
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PNG, TEXT};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
#[cfg(windows)]
|
||||
let cb = Box::<win::Clipboard>::default();
|
||||
#[cfg(not(windows))]
|
||||
let cb = Box::<portable::Clipboard>::default();
|
||||
cb
|
||||
}
|
||||
|
||||
/// The text flavour among `flavours`.
|
||||
fn text_of(flavours: &[Flavour]) -> Option<String> {
|
||||
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
|
||||
}
|
||||
|
||||
fn decode_png(png: &[u8]) -> Option<image::RgbaImage> {
|
||||
Some(image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?.to_rgba8())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win {
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The registered clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
fn names(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
|
||||
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
format!("{e}")
|
||||
}
|
||||
|
||||
/// `png` over white, as a BMP file (what the bitmap format takes).
|
||||
fn opaque_bmp(png: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = decode_png(png)?;
|
||||
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
|
||||
let mut rgb = Vec::with_capacity(img.as_raw().len() / 4 * 3);
|
||||
for p in img.pixels() {
|
||||
let [r, g, b, a] = p.0;
|
||||
rgb.extend([over(r, a), over(g, a), over(b, a)]);
|
||||
}
|
||||
let rgb = image::RgbImage::from_raw(img.width(), img.height(), rgb)?;
|
||||
let mut bmp = vec![];
|
||||
rgb.write_to(&mut Cursor::new(&mut bmp), image::ImageFormat::Bmp).ok()?;
|
||||
Some(bmp)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Clipboard {
|
||||
/// The clipboard's sequence number right after our last write.
|
||||
seq: Option<NonZeroU32>,
|
||||
/// The text of our last write.
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl SystemClipboard for Clipboard {
|
||||
/// Writes every flavour it can; the first failure is reported.
|
||||
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
|
||||
let mut result = Ok(());
|
||||
{
|
||||
let _open = clipboard_win::Clipboard::new_attempts(10).map_err(err)?;
|
||||
raw::empty().map_err(err)?;
|
||||
let mut set = |r: clipboard_win::SysResult<()>| {
|
||||
if result.is_ok() {
|
||||
result = r.map_err(err);
|
||||
}
|
||||
};
|
||||
for f in flavours {
|
||||
if f.mime == TEXT {
|
||||
set(raw::set_string_with(&String::from_utf8_lossy(&f.data), NoClear));
|
||||
continue;
|
||||
}
|
||||
for id in formats_of(f.mime) {
|
||||
set(raw::set_without_clear(id, &f.data));
|
||||
}
|
||||
if f.mime == PNG
|
||||
&& let Some(bmp) = opaque_bmp(&f.data)
|
||||
{
|
||||
set(raw::set_bitmap_with(&bmp, NoClear));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.seq = raw::seq_num();
|
||||
self.text = text_of(flavours);
|
||||
result
|
||||
}
|
||||
|
||||
fn holds_ours(&mut self) -> bool {
|
||||
if self.seq.is_some() && raw::seq_num() == self.seq {
|
||||
return true;
|
||||
}
|
||||
// A clipboard manager may have copied our data again as its own.
|
||||
let Some(ours) = self.text.as_deref() else { return false };
|
||||
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
|
||||
read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
|
||||
mimes.iter().find_map(|m| read_one(m))
|
||||
}
|
||||
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
mimes.iter().any(|m| match *m {
|
||||
TEXT => raw::is_format_avail(formats::CF_UNICODETEXT),
|
||||
BITMAP => raw::is_format_avail(formats::CF_BITMAP) || formats_of(PNG).any(raw::is_format_avail),
|
||||
m => formats_of(m).any(raw::is_format_avail),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the first registered format of `mime` the clipboard holds into `data`.
|
||||
fn read_registered(mime: &str, data: &mut Vec<u8>) -> bool {
|
||||
formats_of(mime).filter(|id| raw::is_format_avail(*id)).any(|id| {
|
||||
data.clear();
|
||||
raw::get_vec(id, data).is_ok() && !data.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// One flavour, while the clipboard is open.
|
||||
fn read_one(mime: &'static str) -> Option<Flavour> {
|
||||
let mut data = vec![];
|
||||
let mime = match mime {
|
||||
BITMAP if read_registered(PNG, &mut data) => PNG,
|
||||
BITMAP => {
|
||||
data.clear();
|
||||
raw::get_bitmap(&mut data).ok()?;
|
||||
"image/bmp"
|
||||
}
|
||||
TEXT => {
|
||||
raw::get_string(&mut data).ok()?;
|
||||
TEXT
|
||||
}
|
||||
m if read_registered(m, &mut data) => m,
|
||||
_ => return None,
|
||||
};
|
||||
// Memory blocks can be longer than the data: SVG text ends at its first NUL.
|
||||
if mime == SVG
|
||||
&& let Some(end) = data.iter().position(|b| *b == 0)
|
||||
{
|
||||
data.truncate(end);
|
||||
}
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
}
|
||||
}
|
||||
|
||||
/// arboard: one format at a time. Built everywhere so it is checked everywhere.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
mod portable {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `img` as PNG bytes.
|
||||
fn encode_png(img: &image::RgbaImage) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// What our last write put on the clipboard.
|
||||
enum Ours {
|
||||
Text(String),
|
||||
Image(usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Clipboard {
|
||||
/// Kept open: on Linux the copied data lives as long as the handle does.
|
||||
cb: Option<arboard::Clipboard>,
|
||||
ours: Option<Ours>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
|
||||
if self.cb.is_none() {
|
||||
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
|
||||
}
|
||||
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemClipboard for Clipboard {
|
||||
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
|
||||
let text = text_of(flavours);
|
||||
let image = match &text {
|
||||
Some(_) => None,
|
||||
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
|
||||
};
|
||||
let cb = self.cb()?;
|
||||
let ours = match (text, image) {
|
||||
(Some(t), _) => {
|
||||
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
|
||||
Some(Ours::Text(t))
|
||||
}
|
||||
(None, Some(img)) => {
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?;
|
||||
Some(Ours::Image(width, height))
|
||||
}
|
||||
(None, None) => {
|
||||
cb.clear().map_err(|e| e.to_string())?;
|
||||
None
|
||||
}
|
||||
};
|
||||
self.ours = ours;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn holds_ours(&mut self) -> bool {
|
||||
let Some(cb) = self.cb.as_mut() else { return false };
|
||||
match &self.ours {
|
||||
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
|
||||
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
let cb = self.cb().ok()?;
|
||||
mimes.iter().find_map(|m| match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
encode_png(&rgba).map(|data| Flavour { mime: PNG, data })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text only: reading a bitmap costs as much as pasting it.
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,18 @@
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
@@ -27,6 +32,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
if self.0.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -36,12 +42,27 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn discard_marked_text() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new()
|
||||
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
|
||||
{
|
||||
ic.discardMarkedText();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
@@ -68,21 +89,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
/// The saved UI preferences, read before the window opens (they hold its size and position).
|
||||
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
|
||||
if let Some(bytes) = bytes
|
||||
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
|
||||
{
|
||||
if let Some(ui) = saved {
|
||||
app.ui = ui.sanitized();
|
||||
}
|
||||
vectorcraft_ui_egui::prefs_dialog::restore(app);
|
||||
}
|
||||
|
||||
fn save_prefs(app: &VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
@@ -90,32 +122,114 @@ fn save_prefs(app: &VectorcraftApp) {
|
||||
let mut ui = app.ui.clone();
|
||||
ui.engine_prefs = app.session.prefs.to_json();
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
|
||||
let _ = std::fs::write(p, bytes);
|
||||
// Preferences are best effort: a failed write keeps the previous file.
|
||||
let _ = fileio::write_atomic(&p, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A native file dialog showing `pick`'s file types, folder and suggested name.
|
||||
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
||||
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
None => d,
|
||||
};
|
||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.args(["-R", path]);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
|
||||
// and needs backslashes.
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(folder);
|
||||
c
|
||||
}
|
||||
|
||||
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"])
|
||||
.pick_file()
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_open_multi: Some(Box::new(|| {
|
||||
fileio::place_filters()
|
||||
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_save: Some(Box::new(|pick: &FilePick| {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
// Menu-bar Paste never sees egui's Paste event, so read the clipboard directly.
|
||||
clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
|
||||
system_clipboard: Some(clipboard::system_clipboard()),
|
||||
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
})),
|
||||
reveal: Some(Box::new(reveal)),
|
||||
// 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(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
// File → Print: the system's printers and print queue.
|
||||
print: Some(Box::new(printing::SystemPrint)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
|
||||
fn open_file(path: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut c = std::process::Command::new("open");
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
#[cfg(not(windows))]
|
||||
c.arg(path);
|
||||
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
|
||||
}
|
||||
|
||||
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
|
||||
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
|
||||
/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon.
|
||||
@@ -127,7 +241,12 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -141,12 +260,15 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
@@ -159,8 +281,24 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
}
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
// Data Recovery copies live next to the preferences too (none for runs without
|
||||
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
|
||||
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second.
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
|
||||
//! when saved views or recent files come and go.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
@@ -9,6 +10,7 @@ use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_ui_egui::VectorcraftApp;
|
||||
use vectorcraft_ui_egui::i18n::{self, Lang, t};
|
||||
use vectorcraft_ui_egui::menus::{self, Item};
|
||||
|
||||
enum Handle {
|
||||
@@ -22,6 +24,12 @@ pub struct NativeMenu {
|
||||
last_refresh: f64,
|
||||
/// Shortcut-override / workspace-list generation the menu was built for.
|
||||
generation: u64,
|
||||
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
|
||||
slots: usize,
|
||||
/// Plug-in registry revision the Plug-ins submenus were built for.
|
||||
plugins: u64,
|
||||
/// The language the labels were built in (a change rebuilds the menu).
|
||||
language: Lang,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
@@ -42,7 +50,7 @@ impl NativeMenu {
|
||||
let mut items = HashMap::new();
|
||||
let mut counter = 0usize;
|
||||
for (title, entries) in menus::menu_tree() {
|
||||
let sub = Submenu::new(title, true);
|
||||
let sub = Submenu::new(t(title), true);
|
||||
build(app, &sub, &entries, &mut items, &mut counter);
|
||||
let _ = menu.append(&sub);
|
||||
}
|
||||
@@ -53,7 +61,15 @@ impl NativeMenu {
|
||||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation }
|
||||
Self {
|
||||
_menu: menu,
|
||||
items,
|
||||
last_refresh: 0.0,
|
||||
generation,
|
||||
slots: menus::listed_slots(app),
|
||||
plugins: menus::plugin_revision(),
|
||||
language: i18n::current(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
@@ -69,8 +85,13 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
|
||||
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
|
||||
// changed: rebuild so accelerators, lists and labels are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
|| menus::plugin_revision() != self.plugins
|
||||
|| i18n::current() != self.language
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +123,7 @@ impl NativeMenu {
|
||||
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
|
||||
)
|
||||
{
|
||||
i.set_text(menus::dynamic_label(app, cmd, ""));
|
||||
i.set_text(menus::display_label(app, cmd, ""));
|
||||
}
|
||||
}
|
||||
Handle::Check(c) => {
|
||||
@@ -127,26 +148,28 @@ fn build(
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Item::Header(h) => {
|
||||
let _ = parent.append(&MenuItem::new(*h, false, None));
|
||||
let _ = parent.append(&MenuItem::new(t(h), false, None));
|
||||
}
|
||||
Item::Todo(label, sc) => {
|
||||
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
|
||||
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(*label, true);
|
||||
let sub = Submenu::new(t(label), true);
|
||||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
|
||||
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
|
||||
Item::Cmd(label, cmd, params) => {
|
||||
*counter += 1;
|
||||
let id = format!("dc{counter}");
|
||||
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
|
||||
let handle = if menus::checked(app, cmd, params).is_some() {
|
||||
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
|
||||
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
|
||||
let _ = parent.append(&c);
|
||||
Handle::Check(c)
|
||||
} else {
|
||||
let i = MenuItem::with_id(id.clone(), *label, true, sc);
|
||||
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
|
||||
let _ = parent.append(&i);
|
||||
Handle::Plain(i)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
|
||||
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
|
||||
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
|
||||
//! the PDF viewer.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
|
||||
/// The system's printing.
|
||||
pub struct SystemPrint;
|
||||
|
||||
impl PrintService for SystemPrint {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
system::printers()
|
||||
}
|
||||
|
||||
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let r = system::print(job, &file);
|
||||
let to = job.printer.unwrap_or("the default printer");
|
||||
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
|
||||
}
|
||||
|
||||
fn has_setup(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
|
||||
system::setup(printer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_names(text: &str) -> Vec<String> {
|
||||
let mut names: Vec<String> = vec![];
|
||||
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
|
||||
if !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
|
||||
/// default destination").
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_default(text: &str) -> Option<String> {
|
||||
let line = text.lines().find(|l| l.contains(':'))?;
|
||||
let name = line.rsplit_once(':')?.1.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
#[cfg(any(test, windows))]
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
#[cfg(any(test, windows))]
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
|
||||
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
|
||||
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
|
||||
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
let mut c = Command::new("lp");
|
||||
if let Some(p) = job.printer {
|
||||
c.args(["-d", p]);
|
||||
}
|
||||
c.args(["-t", job.title]).arg(file);
|
||||
let r = output(&mut c);
|
||||
// `lp` has spooled its own copy (or failed).
|
||||
let _ = std::fs::remove_file(file);
|
||||
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn setup(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = printer;
|
||||
Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.Print-Scan-Settings.extension")
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
if Command::new("system-config-printer").spawn().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// CUPS' own pages.
|
||||
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
|
||||
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new("powershell");
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lpstat_lists_names_and_the_default() {
|
||||
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
|
||||
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
|
||||
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
|
||||
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
|
||||
assert_eq!(lpstat_default("no system default destination\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_lines_become_printers() {
|
||||
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
|
||||
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
/// The first window's size, in points.
|
||||
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
|
||||
/// The smallest window, in points.
|
||||
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pos: [i64; 2],
|
||||
size: [i64; 2],
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||||
let scale = scale as f32;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||||
}
|
||||
|
||||
/// Its size in points.
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size.map(|s| s as f32 / self.scale)
|
||||
}
|
||||
|
||||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||||
fn usable(&self) -> [f32; 2] {
|
||||
let points = self.points();
|
||||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||||
}
|
||||
|
||||
/// A window of `size` points on this monitor, in physical pixels.
|
||||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||||
size.map(|s| (s * self.scale) as i64)
|
||||
}
|
||||
|
||||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||||
let px = self.pixels(size);
|
||||
let span = |i: usize| {
|
||||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||||
(end - start).max(0)
|
||||
};
|
||||
span(0).saturating_mul(span(1))
|
||||
}
|
||||
|
||||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||||
/// points on this monitor.
|
||||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||||
let px = self.pixels(size);
|
||||
std::array::from_fn(|i| {
|
||||
let lo = self.pos[i];
|
||||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||||
return default;
|
||||
};
|
||||
let Some(saved) = saved else {
|
||||
let usable = home.usable();
|
||||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||||
return default;
|
||||
}
|
||||
// A small screen: maximized, with a size that fits once un-maximized.
|
||||
let size = fit(DEFAULT_SIZE, usable);
|
||||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||||
};
|
||||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||||
let shown = saved.pos.and_then(|p| {
|
||||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||||
(area > 0).then_some(m)
|
||||
});
|
||||
let m = shown.unwrap_or(home);
|
||||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||||
let room = m.points();
|
||||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||||
WindowGeometry {
|
||||
// Moved fully onto its monitor, or centred on the home monitor.
|
||||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||||
size,
|
||||
maximized: saved.maximized || too_big,
|
||||
}
|
||||
}
|
||||
|
||||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||||
let g = place(saved, &monitors, home);
|
||||
if let Some([x, y]) = g.pos {
|
||||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(inner) = v.inner_rect else { return };
|
||||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||||
Some(WindowGeometry { pos, size, maximized })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_window_fits_its_screen() {
|
||||
// 1920 × 1080: the default size, where the system puts it.
|
||||
let big = [monitor(0, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
|
||||
// size that fits the screen with room for the taskbar once un-maximized.
|
||||
for (m, size) in [
|
||||
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
|
||||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||||
] {
|
||||
let g = place(None, &[m], Some(0));
|
||||
assert!(g.maximized, "{m:?}");
|
||||
assert_eq!(g.size, size, "{m:?}");
|
||||
let [x, y] = g.pos.unwrap();
|
||||
let px = m.pixels(g.size);
|
||||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||||
}
|
||||
// No monitor information: the default size, whatever was saved.
|
||||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
|
||||
{
|
||||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g.pos, Some([600, 200]));
|
||||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||||
// A monitor left of the primary one (negative coordinates) still counts.
|
||||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = None;
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
let normal = egui::ViewportInfo {
|
||||
native_pixels_per_point: Some(1.5),
|
||||
inner_rect: Some(rect),
|
||||
outer_rect: Some(rect),
|
||||
maximized: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Position in physical pixels, size in points.
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||||
assert_eq!(frame(normal.clone()), Some(g));
|
||||
// Maximized or minimized: the size to restore stays.
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
assert_eq!(frame(normal), Some(g));
|
||||
// Saved with the preferences; older preference files have no window.
|
||||
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
|
||||
assert_eq!(back.window, Some(g));
|
||||
ui.window = None;
|
||||
let json = serde_json::to_value(&ui).unwrap();
|
||||
assert!(json.get("window").is_none());
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.window, None);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ artwork. Licence: see `LICENSE.txt`.
|
||||
| `vectorcraft-macos-512.png` | tile with Apple margin, 512 px | runtime Dock icon (`apps/vectorcraft/src/main.rs`) |
|
||||
| `vectorcraft.icns` | macOS icon set, 16 to 1024 px | `cargo xtask bundle` (`CFBundleIconFile`) |
|
||||
| `vectorcraft.ico` | Windows icon, 16 to 256 px | `apps/vectorcraft/build.rs` (embedded in the `.exe`) |
|
||||
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
|
||||
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux, the 128 one the brand mark in the app bar and About box (`crates/ui-egui/src/brand.rs`) | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
|
||||
| `hicolor/scalable/apps/ai.storyteller.vectorcraft.svg` | Linux scalable icon | as above |
|
||||
|
||||
## How it reaches each OS
|
||||
|
||||
@@ -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="M2 12H14"/><path d="M13 7.5L21.5 12L13 16.5Z" fill="currentColor" stroke="none"/><path d="M14 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 341 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="M2 12H10"/><path d="M9.5 7.5L18 12L9.5 16.5Z" fill="currentColor" stroke="none"/><path d="M18 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 341 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"><rect x="4.75" y="4.75" width="14.5" height="14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 241 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"><rect x="4.75" y="4.75" width="14.5" height="14.5"/><rect x="10" y="10" width="4" height="4" fill="currentColor" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 317 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="M12 5.5A6.5 6.5 0 1 1 5.5 12"/><path d="M3 14.5L5.5 11.5L8 14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 264 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="M12 5.5A6.5 6.5 0 1 0 18.5 12"/><path d="M16 14.5L18.5 11.5L21 14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 268 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"><g stroke-width="1.34"><path d="M3.66 20V9a5.34 5.34 0 0 1 5.34-5.34h11" stroke-opacity="0.2"/><path d="M5 20V9a4 4 0 0 1 4-4h11" stroke-opacity="0.6"/><path d="M6.34 20V9a2.66 2.66 0 0 1 2.66-2.66h11"/></g></svg>
|
||||
|
After Width: | Height: | Size: 396 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"><g stroke-width="4"><path d="M5 20v-5" stroke-opacity="0.2"/><path d="M5 15v-6" stroke-opacity="0.45"/><path d="M5 9a4 4 0 0 1 4-4h2" stroke-opacity="0.7"/><path d="M11 5h9"/></g></svg>
|
||||
|
After Width: | Height: | Size: 368 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"><defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="3" y1="0" x2="21" y2="0"><stop offset="0" stop-color="white" stop-opacity="0.15"/><stop offset="1" stop-color="white"/></linearGradient></defs><path d="M5 20V9a4 4 0 0 1 4-4h11" stroke="url(#g)" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 466 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="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>
|
||||
|
After Width: | Height: | Size: 371 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="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 400 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="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 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 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="evenodd" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 315 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 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="nonzero" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,5 @@
|
||||
# Tests may unwrap, expect and panic: a failing assertion is the point of a test. Shipped code may
|
||||
# not (workspace lints in Cargo.toml; AGENTS.md › Robustness).
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
@@ -83,10 +83,9 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
|
||||
};
|
||||
let half = std::f64::consts::FRAC_PI_2;
|
||||
let mut poly = left.clone();
|
||||
let (pe, te) = *s.last().unwrap();
|
||||
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
|
||||
cap(pe, te, half, -half, &mut poly);
|
||||
poly.extend(right.iter().rev());
|
||||
let (ps, ts) = s[0];
|
||||
cap(ps, ts, -half, -3.0 * half, &mut poly);
|
||||
vec![SubPath::polyline(&poly, true)]
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
|
||||
}
|
||||
|
||||
fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
|
||||
if let Paint::Solid { color, swatch } = p {
|
||||
*color = map_color(color, mode, key);
|
||||
*swatch = None;
|
||||
if let Paint::Solid { color, .. } = p {
|
||||
*p = Paint::solid(map_color(color, mode, key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +82,8 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, swatch } = p {
|
||||
*c = c.lerp(color, amount);
|
||||
*swatch = None;
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
@@ -101,3 +99,19 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]).
|
||||
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
let mut a = art.clone();
|
||||
if let Some((c, amount)) = stain(inst) {
|
||||
tint_node(&mut a, &c, amount);
|
||||
}
|
||||
a
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes}
|
||||
use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter};
|
||||
|
||||
fn fill(c: Color) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], effects: vec![] }
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], ..Default::default() }
|
||||
}
|
||||
|
||||
fn path(p: PathData, c: Color) -> Node {
|
||||
|
||||
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
|
||||
pub use colorize::{colorize, tint_node};
|
||||
pub use colorize::{colorize, instance_art, stain, tint_node};
|
||||
pub use defaults::defaults;
|
||||
pub use serde_json;
|
||||
|
||||
@@ -325,7 +325,11 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String {
|
||||
if !lib.iter().any(|b| b.name == base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !lib.iter().any(|b| &b.name == n)).unwrap()
|
||||
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
|
||||
(2..=lib.len() + 2)
|
||||
.map(|i| format!("{base} {i}"))
|
||||
.find(|n| !lib.iter().any(|b| &b.name == n))
|
||||
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
|
||||
}
|
||||
|
||||
// ---------- geometry ----------
|
||||
@@ -432,7 +436,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
|
||||
|
||||
/// A filled path node painted with `paint` (brush output).
|
||||
pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node {
|
||||
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], effects: vec![] })
|
||||
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], ..Default::default() })
|
||||
}
|
||||
|
||||
/// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).
|
||||
|
||||
@@ -267,7 +267,7 @@ fn bristle_makes_translucent_strands() {
|
||||
#[test]
|
||||
fn tints_colorization_uses_stroke_colour() {
|
||||
let b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
|
||||
let BrushKind::Art(mut a) = b.kind else { unreachable!() };
|
||||
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
|
||||
a.colorization = Colorization::Tints;
|
||||
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
|
||||
|
||||
@@ -76,11 +76,11 @@ impl Track {
|
||||
corner.push(a.dot(b) < cos_limit);
|
||||
tangents.push(unit(a + b));
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => {
|
||||
(a, b) => {
|
||||
// An end of an open track (both `None` only for a track without segments).
|
||||
corner.push(false);
|
||||
tangents.push(a);
|
||||
tangents.push(a.or(b).unwrap_or(Vec2::new(1.0, 0.0)));
|
||||
}
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
Some(Track { verts: pts, cum, closed, dirs, tangents, corner })
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "vectorcraft-cad"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are
|
||||
//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a
|
||||
//! half-saturated tone, and 250–255 are greys.
|
||||
|
||||
use crate::ColorDepth;
|
||||
|
||||
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
|
||||
const STANDARD: [[u8; 3]; 9] =
|
||||
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
|
||||
|
||||
/// The brightness of the five shades of each hue.
|
||||
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
|
||||
|
||||
/// The RGB of colour index `i` (1–255; anything else is index 7's white).
|
||||
pub fn aci_rgb(i: u8) -> [u8; 3] {
|
||||
match i {
|
||||
1..=9 => STANDARD[usize::from(i - 1)],
|
||||
10..=249 => {
|
||||
let hue = f64::from(i / 10 - 1) * 15.0;
|
||||
let k = i % 10;
|
||||
let v = SHADES[usize::from(k / 2)];
|
||||
let pastel = k % 2 == 1;
|
||||
hue_rgb(hue).map(|f| {
|
||||
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
|
||||
(255.0 * v * f).floor() as u8
|
||||
})
|
||||
}
|
||||
250..=255 => {
|
||||
let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8;
|
||||
[g; 3]
|
||||
}
|
||||
_ => STANDARD[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
|
||||
fn hue_rgb(hue: f64) -> [f64; 3] {
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
match h as u8 {
|
||||
0 => [1.0, x, 0.0],
|
||||
1 => [x, 1.0, 0.0],
|
||||
2 => [0.0, 1.0, x],
|
||||
3 => [0.0, x, 1.0],
|
||||
4 => [x, 0.0, 1.0],
|
||||
_ => [1.0, 0.0, x],
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
|
||||
fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
|
||||
match depth {
|
||||
ColorDepth::Aci8 => Box::new(1..=8),
|
||||
ColorDepth::Aci16 => Box::new((1..=9).chain([30]).chain(250..=255)),
|
||||
ColorDepth::Aci256 | ColorDepth::True => Box::new(1..=255),
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index).
|
||||
/// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast.
|
||||
pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 {
|
||||
let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
|
||||
candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_palette_matches_its_known_entries() {
|
||||
let known: [(u8, [u8; 3]); 14] = [
|
||||
(1, [255, 0, 0]),
|
||||
(10, [255, 0, 0]),
|
||||
(11, [255, 127, 127]),
|
||||
(12, [165, 0, 0]),
|
||||
(13, [165, 82, 82]),
|
||||
(17, [76, 38, 38]),
|
||||
(19, [38, 19, 19]),
|
||||
(20, [255, 63, 0]),
|
||||
(21, [255, 159, 127]),
|
||||
(30, [255, 127, 0]),
|
||||
(140, [0, 191, 255]),
|
||||
(250, [51, 51, 51]),
|
||||
(252, [132, 132, 132]),
|
||||
(255, [255, 255, 255]),
|
||||
];
|
||||
for (i, rgb) in known {
|
||||
assert_eq!(aci_rgb(i), rgb, "index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_index_by_depth() {
|
||||
assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1);
|
||||
assert_eq!(nearest_aci([0, 0, 0], ColorDepth::Aci256), 7, "black is index 7");
|
||||
assert_eq!(nearest_aci([255, 255, 255], ColorDepth::Aci16), 7);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci256), 30);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci16), 30);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci8), 1, "8 colours: red is nearest");
|
||||
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci16), 250);
|
||||
assert_eq!(nearest_aci([100, 100, 100], ColorDepth::Aci8), 8);
|
||||
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci8), 7, "dark greys go to black");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! DXF import: an ASCII DXF drawing of any version as a document.
|
||||
//!
|
||||
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
|
||||
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
|
||||
//! or with the drawing's origin on the artboard's bottom-left corner. Each DXF layer holding art
|
||||
//! becomes a layer (hidden, frozen, locked and non-plotting layers keep that), or all art goes on
|
||||
//! one. Lines, polylines with arc segments, circles, arcs, ellipses, splines (NURBS), solid
|
||||
//! hatches and solids become paths; text and multiline text point type; named blocks symbols
|
||||
//! (anonymous ones, such as dimensions, groups of their art). Colours (indexed and true colour),
|
||||
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
|
||||
//! them. What can't come in is listed in the warnings.
|
||||
//!
|
||||
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
|
||||
//! - `entity`: entities to items (geometry and properties).
|
||||
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
|
||||
//! - `text`: text values to plain text.
|
||||
//! - `build`: items to the document.
|
||||
|
||||
mod build;
|
||||
mod curve;
|
||||
mod entity;
|
||||
mod reader;
|
||||
mod text;
|
||||
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
|
||||
pub use reader::BINARY_SENTINEL;
|
||||
|
||||
use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
|
||||
|
||||
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
|
||||
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
|
||||
|
||||
/// Why a binary DXF can't be opened.
|
||||
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
|
||||
|
||||
/// How a drawing comes in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImportOptions {
|
||||
/// The layout (any case): `Model` (`None`, the default) or a paper layout's name.
|
||||
pub layout: Option<String>,
|
||||
/// Scale the art to fit `fit_to` (its larger side along the art's), instead of the ratio.
|
||||
pub fit: bool,
|
||||
/// The artboard Fit fills (points, `width × height`).
|
||||
pub fit_to: (f64, f64),
|
||||
/// The ratio: 1 `unit` of the art is `scale` drawing units. `None`: the drawing's own unit
|
||||
/// at 1:1 (millimetres for unitless metric drawings, inches for imperial ones).
|
||||
pub unit: Option<Unit>,
|
||||
pub scale: Option<f64>,
|
||||
/// Lineweights scale with the art (else they keep their printed widths).
|
||||
pub scale_lineweights: bool,
|
||||
/// The art's centre on the artboard's (else the drawing's origin, or fitted art's
|
||||
/// bottom-left corner, on the artboard's bottom-left corner).
|
||||
pub center: bool,
|
||||
/// All art on one layer.
|
||||
pub merge_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self { layout: None, fit: false, fit_to: FIT_TO, unit: None, scale: None, scale_lineweights: false, center: true, merge_layers: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An imported drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub document: Document,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a drawing holds, for the import options.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DxfInfo {
|
||||
/// The DXF version (`2018`, `R12`…; the `$ACADVER` code when it's none of the known ones).
|
||||
pub version: String,
|
||||
/// The drawing unit's name (`Millimeters`, `Unitless`…).
|
||||
pub units: String,
|
||||
/// `Model`, then the paper layouts.
|
||||
pub layouts: Vec<String>,
|
||||
/// The layer table's layers.
|
||||
pub layers: Vec<String>,
|
||||
/// The default ratio: 1 `unit` = `scale` drawing units.
|
||||
pub unit: Unit,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// Does `bytes` look like a DXF file (ASCII or binary)?
|
||||
pub fn is_dxf(bytes: &[u8]) -> bool {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return true;
|
||||
}
|
||||
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(1024)).unwrap_or(bytes));
|
||||
let mut lines = head.trim_start_matches('\u{feff}').lines().map(str::trim).skip_while(|l| l.is_empty());
|
||||
// Comments (999) may come first; then the first section.
|
||||
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
|
||||
match code {
|
||||
"999" => continue,
|
||||
"0" => return value == "SECTION",
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
|
||||
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return Err(BINARY.into());
|
||||
}
|
||||
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
|
||||
}
|
||||
Ok(reader::decode(bytes))
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
|
||||
fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
if let Some((_, u)) = INSUNITS.iter().find(|(c, _)| i64::from(*c) == h.insunits) {
|
||||
return (*u, 1.0);
|
||||
}
|
||||
match build::insunits_points(h.insunits) {
|
||||
Some(pt) => (Unit::Millimeters, Unit::Millimeters.points() / pt),
|
||||
None if h.measurement == Some(0) => (Unit::Inches, 1.0),
|
||||
None => (Unit::Millimeters, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
"Miles",
|
||||
"Millimeters",
|
||||
"Centimeters",
|
||||
"Meters",
|
||||
"Kilometers",
|
||||
"Microinches",
|
||||
"Mils",
|
||||
"Yards",
|
||||
"Angstroms",
|
||||
"Nanometers",
|
||||
"Microns",
|
||||
"Decimeters",
|
||||
"Decameters",
|
||||
"Hectometers",
|
||||
"Gigameters",
|
||||
"Astronomical units",
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
/// Read what `bytes` holds: its version, units, layouts and layers.
|
||||
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
let text = text(bytes)?;
|
||||
let (pairs, _) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
let h = &d.header;
|
||||
let (unit, scale) = default_ratio(h);
|
||||
let version = DxfVersion::ALL.into_iter().find(|v| v.acadver() == h.acadver).map_or_else(|| h.acadver.clone(), |v| v.id().to_string());
|
||||
Ok(DxfInfo {
|
||||
version,
|
||||
units: units_name(h.insunits).into(),
|
||||
layouts: d.layouts.iter().map(|l| l.name.clone()).collect(),
|
||||
layers: d.layers.iter().map(|l| l.name.clone()).collect(),
|
||||
unit,
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an ASCII DXF drawing with `o`.
|
||||
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (w, h) = o.fit_to;
|
||||
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
|
||||
return Err(format!("the artboard to fit must be a positive size up to {} pt", build::MAX_EXTENT));
|
||||
}
|
||||
if let Some(s) = o.scale
|
||||
&& !(s.is_finite() && SCALE_RANGE.contains(&s))
|
||||
{
|
||||
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
|
||||
}
|
||||
let text = text(bytes)?;
|
||||
let (pairs, damaged) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
let layout = match &o.layout {
|
||||
None => d.layouts.first(),
|
||||
Some(name) => d.layout(name),
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
let names: Vec<&str> = d.layouts.iter().map(|l| l.name.as_str()).collect();
|
||||
return Err(format!("the drawing has no layout `{}` (it has {})", o.layout.as_deref().unwrap_or_default(), names.join(", ")));
|
||||
};
|
||||
let (unit, scale) = default_ratio(&d.header);
|
||||
let natural = unit.points() / scale;
|
||||
let ratio = match (o.unit, o.scale) {
|
||||
(None, None) => natural,
|
||||
(u, s) => u.unwrap_or(unit).points() / s.unwrap_or(1.0),
|
||||
};
|
||||
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
|
||||
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
|
||||
warnings.extend(damaged);
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Drawing items to a document: the scale and position the options ask for, one layer per DXF
|
||||
//! layer that holds art (or one in all), named blocks as symbols (anonymous ones, such as
|
||||
//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through
|
||||
//! layers and blocks.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::BezPath;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAYER_COLORS, LayerColor, LineCap, Node, NodeKind, Symbol, TextObject};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
|
||||
use super::ImportOptions;
|
||||
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
|
||||
use super::reader::{Drawing, LayoutDef};
|
||||
use crate::{CAP_HEIGHT, MAX_NEST};
|
||||
|
||||
/// Points per millimetre.
|
||||
const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
/// The stroke weight of lineweight 0 (CAD apps draw it one device pixel wide), in points.
|
||||
const THINNEST: f64 = 0.25;
|
||||
/// The most objects an import makes.
|
||||
const MAX_NODES: usize = 2_000_000;
|
||||
/// How far from the origin art may lie (points): the canvas.
|
||||
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
|
||||
|
||||
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
|
||||
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
|
||||
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
|
||||
|
||||
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
|
||||
/// and opacity where the art says "by block", and its layer for art on layer 0.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
struct Inherit {
|
||||
color: Option<[u8; 3]>,
|
||||
lineweight: Option<i32>,
|
||||
linetype: Option<String>,
|
||||
alpha: Option<u8>,
|
||||
layer: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct Build<'d, 'a> {
|
||||
d: &'d Drawing<'a>,
|
||||
o: &'d ImportOptions,
|
||||
conv: Converter,
|
||||
doc: Document,
|
||||
/// Block items by upper-case name.
|
||||
blocks: HashMap<String, Rc<Vec<Item>>>,
|
||||
bounds: HashMap<String, Option<Rect>>,
|
||||
sensitive: HashMap<String, bool>,
|
||||
/// Symbol names by block and what it inherits (`None`: the block has no art).
|
||||
symbols: HashMap<(String, Inherit), Option<String>>,
|
||||
/// Blocks being drawn, outermost first.
|
||||
stack: Vec<String>,
|
||||
/// Document space per drawing unit, and its symbols' (no translation).
|
||||
map: Affine,
|
||||
symbol_map: Affine,
|
||||
/// Lineweights are multiplied by this.
|
||||
lw_scale: f64,
|
||||
nodes: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
3 => 72.0 * 63_360.0,
|
||||
4 => PT_PER_MM,
|
||||
5 => PT_PER_MM * 10.0,
|
||||
6 => m,
|
||||
7 => m * 1e3,
|
||||
8 => 72e-6,
|
||||
9 => 72e-3,
|
||||
10 => 2592.0,
|
||||
11 => m * 1e-10,
|
||||
12 => m * 1e-9,
|
||||
13 => m * 1e-6,
|
||||
14 => m * 0.1,
|
||||
15 => m * 10.0,
|
||||
16 => m * 100.0,
|
||||
17 => m * 1e9,
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl<'d, 'a> Build<'d, 'a> {
|
||||
pub fn new(d: &'d Drawing<'a>, o: &'d ImportOptions) -> Self {
|
||||
Self {
|
||||
d,
|
||||
o,
|
||||
conv: Converter::default(),
|
||||
doc: Document::new(1.0, 1.0),
|
||||
blocks: HashMap::new(),
|
||||
bounds: HashMap::new(),
|
||||
sensitive: HashMap::new(),
|
||||
symbols: HashMap::new(),
|
||||
stack: vec![],
|
||||
map: Affine::IDENTITY,
|
||||
symbol_map: Affine::IDENTITY,
|
||||
lw_scale: 1.0,
|
||||
nodes: 0,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own
|
||||
/// unit and at the scale asked for (fitting computes its own).
|
||||
pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec<String>), String> {
|
||||
let items = self.conv.items(&self.d.layout_records(layout));
|
||||
let bounds = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, 0)));
|
||||
let (k, size, offset) = self.placement(bounds, natural, ratio)?;
|
||||
self.map = Affine::new([k, 0.0, 0.0, -k, offset.x, offset.y]);
|
||||
self.symbol_map = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]);
|
||||
self.lw_scale = if self.o.scale_lineweights { k / natural } else { 1.0 };
|
||||
let layers = self.layers(&items);
|
||||
let Self { mut doc, conv, warnings: notes, .. } = self;
|
||||
if !layers.is_empty() {
|
||||
doc.layers = layers;
|
||||
}
|
||||
if let Some(a) = doc.artboards.first_mut() {
|
||||
a.rect = Rect::from_origin_size(Point::ZERO, size);
|
||||
}
|
||||
doc.fix_next_id();
|
||||
let mut warnings = conv.warnings;
|
||||
warnings.extend(notes);
|
||||
if !conv.skipped.is_empty() {
|
||||
let list: Vec<String> = conv.skipped.iter().map(|(k, n)| format!("{n} {k}")).collect();
|
||||
warnings.push(format!("entities of these types are left out: {}", list.join(", ")));
|
||||
}
|
||||
if bounds.is_none() {
|
||||
warnings.push(format!("the {} layout has no art", layout.name));
|
||||
}
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// Points per drawing unit, the artboard's size, and where the drawing's origin goes.
|
||||
fn placement(&self, bounds: Option<Rect>, natural: f64, ratio: f64) -> Result<(f64, vectorcraft_geom::Size, Point), String> {
|
||||
let b = bounds.unwrap_or(Rect::ZERO);
|
||||
let k = if self.o.fit {
|
||||
let (w, h) = self.fit_box(b);
|
||||
let ratio = |a: f64, b: f64| (b > 1e-12).then(|| a / b);
|
||||
[ratio(w, b.width()), ratio(h, b.height())].into_iter().flatten().reduce(f64::min).unwrap_or(natural)
|
||||
} else {
|
||||
ratio
|
||||
};
|
||||
if !(k.is_finite() && k > 0.0) {
|
||||
return Err("the scale is not a usable number".into());
|
||||
}
|
||||
let size = if self.o.fit {
|
||||
let (w, h) = self.fit_box(b);
|
||||
vectorcraft_geom::Size::new(w, h)
|
||||
} else {
|
||||
vectorcraft_geom::Size::new((b.width() * k).max(1.0), (b.height() * k).max(1.0))
|
||||
};
|
||||
// Centred: the art's centre on the artboard's; else the drawing's origin (fitted art: its
|
||||
// bottom-left corner) on the artboard's bottom-left corner.
|
||||
let offset = if self.o.center {
|
||||
Point::new(size.width / 2.0 - b.center().x * k, size.height / 2.0 + b.center().y * k)
|
||||
} else if self.o.fit {
|
||||
Point::new(-b.x0 * k, size.height + b.y0 * k)
|
||||
} else {
|
||||
Point::new(0.0, size.height)
|
||||
};
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)].map(|p| Point::new(p.x * k + offset.x, -p.y * k + offset.y));
|
||||
let fits = corners.iter().all(|p| p.x.abs() <= MAX_EXTENT && p.y.abs() <= MAX_EXTENT) && size.width.max(size.height) <= MAX_EXTENT;
|
||||
if !fits {
|
||||
let (w, h) = (b.width() * k, b.height() * k);
|
||||
return Err(format!(
|
||||
"at this scale the drawing is {w:.0} × {h:.0} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard"
|
||||
));
|
||||
}
|
||||
Ok((k, size, offset))
|
||||
}
|
||||
|
||||
/// The box Fit to Artboard fills, turned to the art's orientation.
|
||||
fn fit_box(&self, b: Rect) -> (f64, f64) {
|
||||
let (w, h) = self.o.fit_to;
|
||||
if (b.width() > b.height()) == (w > h) { (w, h) } else { (h, w) }
|
||||
}
|
||||
|
||||
// ---------- blocks ----------
|
||||
|
||||
/// A block's items (read once).
|
||||
fn block(&mut self, name: &str) -> Option<Rc<Vec<Item>>> {
|
||||
if let Some(b) = self.blocks.get(name) {
|
||||
return Some(b.clone());
|
||||
}
|
||||
let def = self.d.blocks.get(name)?;
|
||||
if def.xref {
|
||||
self.warn(XREFS);
|
||||
return None;
|
||||
}
|
||||
let items = Rc::new(self.conv.items(&def.records));
|
||||
self.blocks.insert(name.to_string(), items.clone());
|
||||
Some(items)
|
||||
}
|
||||
|
||||
/// Enter block `name` `depth` deep; false (with a warning) when too deep or inside itself.
|
||||
fn enter(&mut self, name: &str, depth: u32) -> bool {
|
||||
if depth >= MAX_NEST || self.stack.iter().any(|s| s == name) {
|
||||
self.warn(NESTED);
|
||||
return false;
|
||||
}
|
||||
self.stack.push(name.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// A block's bounds in its coordinates less its base point.
|
||||
fn block_bounds(&mut self, name: &str, depth: u32) -> Option<Rect> {
|
||||
if let Some(b) = self.bounds.get(name) {
|
||||
return *b;
|
||||
}
|
||||
let items = self.block(name)?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
}
|
||||
let b = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, depth + 1)));
|
||||
self.stack.pop();
|
||||
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
|
||||
let b = b.map(|r| r - base.to_vec2());
|
||||
self.bounds.insert(name.to_string(), b);
|
||||
b
|
||||
}
|
||||
|
||||
/// An item's bounds in world coordinates (type: an estimate from its height and length).
|
||||
fn item_bounds(&mut self, it: &Item, depth: u32) -> Option<Rect> {
|
||||
match &it.geom {
|
||||
Geom::Path { bp, .. } => super::curve::bounds(bp),
|
||||
Geom::Text(t) => {
|
||||
let lines = t.text.lines().count().max(1) as f64;
|
||||
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
||||
let natural = longest * t.height * 0.9 * t.width;
|
||||
// Fit and aligned text run their span; aligned text's height scales with it.
|
||||
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
|
||||
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
|
||||
let w = natural * k;
|
||||
let x0 = match t.justify {
|
||||
vectorcraft_doc::Justify::Center => -w / 2.0,
|
||||
vectorcraft_doc::Justify::Right => -w,
|
||||
_ => 0.0,
|
||||
};
|
||||
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
|
||||
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
|
||||
}
|
||||
Geom::Insert(ins) => {
|
||||
let b = self.block_bounds(&ins.block, depth);
|
||||
let cells = b.into_iter().flat_map(|b| ins.cells.iter().map(move |m| m.transform_rect_bbox(b)));
|
||||
let attribs: Vec<Option<Rect>> = ins.attribs.iter().map(|a| self.item_bounds(a, depth)).collect();
|
||||
cells.map(Some).chain(attribs).fold(None, vectorcraft_geom::union_opt)
|
||||
}
|
||||
}
|
||||
.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))
|
||||
}
|
||||
|
||||
/// Does a block's look depend on its insert (art "by block", or on layer 0 "by layer")?
|
||||
fn is_sensitive(&mut self, name: &str, depth: u32) -> bool {
|
||||
if let Some(s) = self.sensitive.get(name) {
|
||||
return *s;
|
||||
}
|
||||
let Some(items) = self.block(name) else { return false };
|
||||
if !self.enter(name, depth) {
|
||||
return false;
|
||||
}
|
||||
let mut s = false;
|
||||
for it in items.iter() {
|
||||
let p = &it.props;
|
||||
let on_zero = p.layer == "0";
|
||||
s |= p.color == Col::ByBlock || p.lineweight == Lw::ByBlock || p.linetype == "BYBLOCK" || p.alpha == Alpha::ByBlock;
|
||||
s |= on_zero && (p.color == Col::ByLayer || p.lineweight == Lw::ByLayer || p.linetype == "BYLAYER");
|
||||
if let Geom::Insert(ins) = &it.geom {
|
||||
s |= !ins.attribs.is_empty() || self.is_sensitive(&ins.block, depth + 1);
|
||||
}
|
||||
if s {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.stack.pop();
|
||||
self.sensitive.insert(name.to_string(), s);
|
||||
s
|
||||
}
|
||||
|
||||
// ---------- properties ----------
|
||||
|
||||
/// The layer art on layer 0 takes from its block, else its own.
|
||||
fn layer_name<'p>(&self, p: &'p Props, inh: &'p Inherit) -> &'p str {
|
||||
match &inh.layer {
|
||||
Some(l) if p.layer == "0" => l,
|
||||
_ => &p.layer,
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(&self, p: &Props, inh: &Inherit) -> [u8; 3] {
|
||||
match p.color {
|
||||
Col::Rgb(c) => c,
|
||||
Col::ByBlock => inh.color.unwrap_or([0; 3]),
|
||||
Col::ByLayer => self.d.layer(self.layer_name(p, inh)).map_or([0; 3], |l| l.rgb),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lineweight in hundredths of a millimetre.
|
||||
fn lineweight(&self, p: &Props, inh: &Inherit) -> i32 {
|
||||
let default = self.d.header.lwdefault;
|
||||
match p.lineweight {
|
||||
Lw::Hundredths(v) => v,
|
||||
Lw::Default => default,
|
||||
Lw::ByBlock => inh.lineweight.unwrap_or(default),
|
||||
Lw::ByLayer => self.d.layer(self.layer_name(p, inh)).map(|l| l.lineweight).filter(|v| *v >= 0).unwrap_or(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The linetype name (upper case) after "by layer" and "by block".
|
||||
fn linetype(&self, p: &Props, inh: &Inherit) -> String {
|
||||
match p.linetype.as_str() {
|
||||
"BYLAYER" => self.d.layer(self.layer_name(p, inh)).map_or_else(|| "CONTINUOUS".into(), |l| l.linetype.clone()),
|
||||
"BYBLOCK" => inh.linetype.clone().unwrap_or_else(|| "CONTINUOUS".into()),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opacity(&self, p: &Props, inh: &Inherit) -> f32 {
|
||||
match p.alpha {
|
||||
Alpha::Opacity(a) => a,
|
||||
Alpha::ByBlock => inh.alpha.map_or(1.0, |a| f32::from(a) / 255.0),
|
||||
Alpha::ByLayer => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block placed by an insert with `p` inherits.
|
||||
fn inherit(&self, p: &Props, inh: &Inherit) -> Inherit {
|
||||
Inherit {
|
||||
color: Some(self.rgb(p, inh)),
|
||||
lineweight: Some(self.lineweight(p, inh)),
|
||||
linetype: Some(self.linetype(p, inh)),
|
||||
alpha: Some((self.opacity(p, inh) * 255.0).round() as u8),
|
||||
layer: Some(self.layer_name(p, inh).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dash pattern in points for `linetype` drawn `scale` points per drawing unit.
|
||||
fn dash(&self, linetype: &str, p: &Props, scale: f64) -> Option<(Dash, bool)> {
|
||||
let pattern = self.d.linetypes.get(linetype)?;
|
||||
let k = self.d.header.ltscale * p.ltscale * scale;
|
||||
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
|
||||
let mut out: Vec<f64> = vec![];
|
||||
for v in pattern {
|
||||
let dash = *v >= 0.0;
|
||||
let len = v.abs() * k;
|
||||
match out.len().is_multiple_of(2) {
|
||||
expects_dash if expects_dash == dash => out.push(len),
|
||||
_ if out.is_empty() => out.extend([0.0, len]),
|
||||
_ => {
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !out.len().is_multiple_of(2) {
|
||||
out.push(0.0);
|
||||
}
|
||||
let dots = out.iter().step_by(2).any(|d| *d <= 1e-9);
|
||||
let d = Dash { pattern: out, offset: 0.0, align_corners: false };
|
||||
(d.is_dashed() && d.pattern.iter().skip(1).step_by(2).any(|g| *g > 1e-9) && d.pattern.iter().all(|v| v.is_finite())).then_some((d, dots))
|
||||
}
|
||||
|
||||
// ---------- art ----------
|
||||
|
||||
/// One doc layer per DXF layer holding art (in the order art first uses them), or one for all.
|
||||
fn layers(&mut self, items: &[Item]) -> Vec<Arc<Node>> {
|
||||
let mut layers: Vec<(String, Vec<Arc<Node>>)> = vec![];
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let top = Inherit::default();
|
||||
let map = self.map;
|
||||
for it in items {
|
||||
let Some(node) = self.item(it, map, &top, 0) else { continue };
|
||||
let name = if self.o.merge_layers { "" } else { it.props.layer.as_str() };
|
||||
let i = *index.entry(name.to_uppercase()).or_insert_with(|| {
|
||||
layers.push((name.to_string(), vec![]));
|
||||
layers.len() - 1
|
||||
});
|
||||
if let Some((_, children)) = layers.get_mut(i) {
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
}
|
||||
layers
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, children))| {
|
||||
let id = self.doc.alloc_id();
|
||||
let color = LayerColor::Preset((i % LAYER_COLORS.len()) as u8);
|
||||
let label = if name.is_empty() { "Layer 1" } else { &name };
|
||||
let mut l = Node::layer(id, label, color);
|
||||
if let Some(def) = self.d.layer(&name) {
|
||||
l.visible = !def.hidden;
|
||||
l.locked = def.locked;
|
||||
if let NodeKind::Layer { printable, .. } = &mut l.kind {
|
||||
*printable = def.plot;
|
||||
}
|
||||
}
|
||||
if let Some(c) = l.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
Arc::new(l)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One item as an object, drawn by `map` (world coordinates to its parent's space).
|
||||
fn item(&mut self, it: &Item, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
if self.nodes >= MAX_NODES {
|
||||
self.warn(TOO_MANY);
|
||||
return None;
|
||||
}
|
||||
let mut node = match &it.geom {
|
||||
Geom::Path { bp, fill, width } => self.path(bp, *fill, *width, &it.props, map, inh)?,
|
||||
Geom::Text(t) => self.text(t, &it.props, map, inh)?,
|
||||
Geom::Insert(ins) => return self.insert(ins, &it.props, map, inh, depth),
|
||||
};
|
||||
node.opacity = self.opacity(&it.props, inh);
|
||||
self.nodes += 1;
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
|
||||
let mut bp = bp.clone();
|
||||
bp.apply_affine(map);
|
||||
super::curve::bounds(&bp)?;
|
||||
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
|
||||
let scale = map.determinant().abs().sqrt();
|
||||
let appearance = if fill {
|
||||
Appearance::basic(paint, Paint::None, 0.0)
|
||||
} else {
|
||||
let lw = self.lineweight(p, inh);
|
||||
let w = if width > 0.0 {
|
||||
width * scale
|
||||
} else if lw == 0 {
|
||||
THINNEST
|
||||
} else {
|
||||
f64::from(lw) / 100.0 * PT_PER_MM * self.lw_scale
|
||||
};
|
||||
let mut ap = Appearance::basic(Paint::None, paint, w);
|
||||
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
|
||||
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
|
||||
{
|
||||
s.dash = Some(dash);
|
||||
if dots {
|
||||
s.cap = LineCap::Round;
|
||||
}
|
||||
}
|
||||
ap
|
||||
};
|
||||
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
|
||||
let mut n = if path.subpaths.len() > 1 {
|
||||
// Several loops (a hatch with islands) paint as one compound path.
|
||||
let children = path
|
||||
.subpaths
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
|
||||
c.appearance = appearance.clone();
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })
|
||||
} else {
|
||||
Node::new(self.doc.alloc_id(), kind(path))
|
||||
};
|
||||
n.appearance = appearance;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &TextItem, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
|
||||
// Text space is y-down points; the item's is y-up drawing units.
|
||||
let m = map * t.xf * Affine::FLIP_Y;
|
||||
let s = m.determinant().abs().sqrt();
|
||||
if !(s > 1e-12 && s.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned());
|
||||
let default = CharStyle::default();
|
||||
let style = CharStyle {
|
||||
font_family: family.unwrap_or(default.font_family.clone()),
|
||||
size: t.height / CAP_HEIGHT * s,
|
||||
h_scale: t.width * 100.0,
|
||||
leading: t.leading.map(|l| l * s),
|
||||
fill: Paint::solid(rgb_color(self.rgb(p, inh))),
|
||||
..default
|
||||
};
|
||||
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
|
||||
obj.xf = m * Affine::scale(1.0 / s);
|
||||
obj.para.justify = t.justify;
|
||||
if let Some(span) = t.span {
|
||||
fill_span(&mut obj, span, s);
|
||||
}
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
|
||||
}
|
||||
|
||||
/// An insert: a symbol instance per copy (anonymous blocks: a group of their art), grouped
|
||||
/// with its attributes.
|
||||
fn insert(&mut self, ins: &InsertItem, p: &Props, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
let child = self.inherit(p, inh);
|
||||
let base = self.d.blocks.get(&ins.block).map_or(Point::ZERO, |b| b.base);
|
||||
let unbase = Affine::translate(-base.to_vec2());
|
||||
let anonymous = ins.block.starts_with('*');
|
||||
let symbol = if anonymous { None } else { self.symbol(&ins.block, &child, depth) };
|
||||
let mut parts = vec![];
|
||||
for cell in &ins.cells {
|
||||
let node = match &symbol {
|
||||
Some(name) => {
|
||||
let xf = map * *cell * self.symbol_map.inverse();
|
||||
self.nodes += 1;
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf }))
|
||||
}
|
||||
None if anonymous => self.art(&ins.block, map * *cell * unbase, &child, depth),
|
||||
None => None,
|
||||
};
|
||||
parts.extend(node);
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
parts.extend(self.item(a, map, &child, depth));
|
||||
}
|
||||
let mut node = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.pop()?,
|
||||
_ => Node::group(self.doc.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
node.opacity = self.opacity(p, inh);
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// The art of block `name` drawn by `map`, as one object.
|
||||
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
let items = self.block(name)?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
}
|
||||
let mut nodes = vec![];
|
||||
for it in items.iter() {
|
||||
// Art on a hidden layer stays hidden inside blocks.
|
||||
if self.d.layer(self.layer_name(&it.props, inh)).is_some_and(|l| l.hidden) && it.props.layer != "0" {
|
||||
continue;
|
||||
}
|
||||
nodes.extend(self.item(it, map, inh, depth + 1));
|
||||
}
|
||||
self.stack.pop();
|
||||
match nodes.len() {
|
||||
0 => None,
|
||||
1 => nodes.pop(),
|
||||
_ => Some(Node::group(self.doc.alloc_id(), nodes.into_iter().map(Arc::new).collect())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol for block `name` placed with `inh` (made once per look).
|
||||
fn symbol(&mut self, name: &str, inh: &Inherit, depth: u32) -> Option<String> {
|
||||
let inh = if self.is_sensitive(name, depth) { inh.clone() } else { Inherit::default() };
|
||||
let key = (name.to_string(), inh);
|
||||
if let Some(s) = self.symbols.get(&key) {
|
||||
return s.clone();
|
||||
}
|
||||
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
|
||||
let map = self.symbol_map * Affine::translate(-base.to_vec2());
|
||||
let art = self.art(name, map, &key.1, depth);
|
||||
let made = art.map(|art| {
|
||||
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
|
||||
let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n));
|
||||
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
|
||||
symbol
|
||||
});
|
||||
self.symbols.insert(key, made.clone());
|
||||
made
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
|
||||
/// (`s`: points per drawing unit), measured in the font it is set in.
|
||||
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
|
||||
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
|
||||
let Some(k) = span.scale(natural / s) else { return };
|
||||
for r in &mut obj.runs {
|
||||
match span {
|
||||
Span::Fit(_) => r.style.h_scale *= k,
|
||||
Span::Aligned(_) => r.style.size *= k,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
|
||||
Color::rgb8(r, g, b)
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… when taken.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! CAD curves as Bézier paths: polylines with bulges (arc segments), circular and elliptical
|
||||
//! arcs, and splines (NURBS of any degree, rational or not, or a curve through fit points).
|
||||
//!
|
||||
//! Curves are built in drawing units with tolerances relative to their own size, so they keep
|
||||
//! their accuracy at any scale the drawing is imported at.
|
||||
|
||||
use kurbo::{Arc, BezPath, Shape, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
/// Arcs are approximated to within this fraction of their radius.
|
||||
const ARC_TOLERANCE: f64 = 1e-6;
|
||||
/// Splines are approximated to within this fraction of their control polygon's size.
|
||||
const SPLINE_TOLERANCE: f64 = 1e-6;
|
||||
/// The most Bézier pieces one spline span may become.
|
||||
const MAX_DEPTH: u32 = 12;
|
||||
/// The most path elements one spline may become (beyond, its pieces aren't split further).
|
||||
const MAX_ELEMENTS: usize = 200_000;
|
||||
/// The highest spline degree read.
|
||||
const MAX_DEGREE: usize = 11;
|
||||
|
||||
/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a
|
||||
/// quarter of the arc's angle; positive turns counter-clockwise).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Vertex {
|
||||
pub p: Point,
|
||||
pub bulge: f64,
|
||||
}
|
||||
|
||||
/// A polyline: straight segments, and arcs where a vertex has a bulge.
|
||||
pub(crate) fn polyline(verts: &[Vertex], closed: bool) -> Option<BezPath> {
|
||||
let first = verts.first()?;
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first.p);
|
||||
let n = verts.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { break };
|
||||
bulge_to(&mut bp, a.p, b.p, a.bulge);
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A segment from `a` (the current point) to `b`, an arc when `bulge` isn't 0.
|
||||
fn bulge_to(bp: &mut BezPath, a: Point, b: Point, bulge: f64) {
|
||||
let chord = b - a;
|
||||
let len = chord.hypot();
|
||||
if bulge.abs() < 1e-12 || len < 1e-12 || !bulge.is_finite() {
|
||||
bp.line_to(b);
|
||||
return;
|
||||
}
|
||||
// The included angle is 4·atan(bulge); the centre lies off the chord's middle.
|
||||
let sweep = 4.0 * bulge.atan();
|
||||
let radius = len / (2.0 * (sweep / 2.0).sin()).abs();
|
||||
let normal = Vec2::new(-chord.y, chord.x) / len;
|
||||
let offset = (radius * radius - len * len / 4.0).max(0.0).sqrt();
|
||||
// A counter-clockwise arc under a half turn has its centre left of the chord.
|
||||
let side = if (bulge > 0.0) == (bulge.abs() < 1.0) { 1.0 } else { -1.0 };
|
||||
let centre = a.midpoint(b) + normal * offset * side;
|
||||
let start = (a - centre).atan2();
|
||||
arc_to(bp, centre, radius, start, sweep);
|
||||
}
|
||||
|
||||
/// An arc of a circle from angle `start` (radians) through `sweep` (positive: counter-clockwise),
|
||||
/// from the current point.
|
||||
fn arc_to(bp: &mut BezPath, centre: Point, radius: f64, start: f64, sweep: f64) {
|
||||
let arc = Arc::new(centre, (radius, radius), start, sweep, 0.0);
|
||||
for el in arc.append_iter((radius * ARC_TOLERANCE).max(1e-12)) {
|
||||
bp.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular arc from `start` through `sweep` radians as its own subpath; a whole circle closes.
|
||||
pub(crate) fn arc(centre: Point, radius: f64, start: f64, sweep: f64) -> Option<BezPath> {
|
||||
if !(radius > 0.0 && radius.is_finite() && sweep.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(centre + Vec2::from_angle(start) * radius);
|
||||
arc_to(&mut bp, centre, radius, start, sweep);
|
||||
if full {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// An elliptical arc: the unit circle's arc from `start` through `sweep` radians mapped by `frame`
|
||||
/// (its columns are the major and minor semi-axes, its translation the centre).
|
||||
pub(crate) fn ellipse(frame: Affine, start: f64, sweep: f64) -> Option<BezPath> {
|
||||
let [a, b, c, d, _, _] = frame.as_coeffs();
|
||||
let size = a.hypot(b).max(c.hypot(d));
|
||||
if !(size > 1e-12 && size.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
|
||||
let unit = Arc::new(Point::ZERO, (1.0, 1.0), start, sweep, 0.0);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(Point::ZERO + Vec2::from_angle(start));
|
||||
for el in unit.append_iter(ARC_TOLERANCE) {
|
||||
bp.push(el);
|
||||
}
|
||||
if full {
|
||||
bp.close_path();
|
||||
}
|
||||
bp.apply_affine(frame);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A point with a weight, in homogeneous form (`x·w, y·w, w`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct H {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
}
|
||||
|
||||
impl H {
|
||||
fn lerp(self, o: Self, t: f64) -> Self {
|
||||
Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t }
|
||||
}
|
||||
fn point(self) -> Option<Point> {
|
||||
(self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
/// A NURBS curve of `degree` with `knots`, control points `ctrl` and their `weights` (none:
|
||||
/// all 1) as Bézier pieces: exact for polynomial curves up to degree 3, else within
|
||||
/// [`SPLINE_TOLERANCE`] of the control polygon's size.
|
||||
pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Option<&[f64]>) -> Option<BezPath> {
|
||||
let n = ctrl.len();
|
||||
if !(1..=MAX_DEGREE).contains(°ree) || n <= degree || knots.len() != n + degree + 1 {
|
||||
return None;
|
||||
}
|
||||
if knots.windows(2).any(|w| w[1] < w[0]) {
|
||||
return None;
|
||||
}
|
||||
let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
|
||||
let hs: Vec<H> = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect();
|
||||
let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12);
|
||||
let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
|
||||
(Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||
});
|
||||
let tol = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12);
|
||||
let mut bp = BezPath::new();
|
||||
// Each knot span of the domain is one polynomial piece: its Bézier points are blossoms.
|
||||
for span in degree..n {
|
||||
let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue };
|
||||
if t1 - t0 <= 1e-12 * (1.0 + t0.abs()) {
|
||||
continue;
|
||||
}
|
||||
let bez: Vec<H> = (0..=degree)
|
||||
.map(|j| {
|
||||
let args: Vec<f64> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect();
|
||||
blossom(degree, knots, &hs, span, &args)
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let start = bez.first().and_then(|h| h.point())?;
|
||||
if bp.elements().is_empty() {
|
||||
bp.move_to(start);
|
||||
}
|
||||
let pts: Option<Vec<Point>> = if rational { None } else { bez.iter().map(|h| h.point()).collect() };
|
||||
match pts.as_deref() {
|
||||
Some([_, p]) => bp.line_to(*p),
|
||||
Some([_, q, p]) => bp.quad_to(*q, *p),
|
||||
Some([_, a, b, p]) => bp.curve_to(*a, *b, *p),
|
||||
_ => fit(&mut bp, &bez, 0.0, 1.0, tol, 0)?,
|
||||
}
|
||||
}
|
||||
(bp.elements().len() > 1).then_some(bp)
|
||||
}
|
||||
|
||||
/// The blossom of span `span` at `args` (de Boor's algorithm with one parameter per level).
|
||||
fn blossom(degree: usize, knots: &[f64], ctrl: &[H], span: usize, args: &[f64]) -> Option<H> {
|
||||
let mut d: Vec<H> = ctrl.get(span - degree..=span)?.to_vec();
|
||||
for r in 1..=degree {
|
||||
let u = *args.get(r - 1)?;
|
||||
for j in (r..=degree).rev() {
|
||||
let i = span - degree + j;
|
||||
let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?);
|
||||
let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 };
|
||||
let prev = *d.get(j - 1)?;
|
||||
let cur = d.get_mut(j)?;
|
||||
*cur = prev.lerp(*cur, alpha);
|
||||
}
|
||||
}
|
||||
d.get(degree).copied()
|
||||
}
|
||||
|
||||
/// A (rational) Bézier of any degree at `t`, with its derivative.
|
||||
fn eval(bez: &[H], t: f64) -> Option<(Point, Vec2)> {
|
||||
let mut pts = bez.to_vec();
|
||||
let n = pts.len().checked_sub(1)?;
|
||||
// Down to the two points whose difference gives the tangent.
|
||||
for level in (1..=n).rev() {
|
||||
if level == 1 {
|
||||
let (a, b) = (*pts.first()?, *pts.get(1)?);
|
||||
let p = a.lerp(b, t);
|
||||
let pt = p.point()?;
|
||||
// d/dt (X/W) = (X'·W − X·W') / W², with X' = n·(b − a).
|
||||
let k = n as f64;
|
||||
let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w));
|
||||
let w2 = p.w * p.w;
|
||||
let tangent = Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2);
|
||||
return Some((pt, tangent));
|
||||
}
|
||||
for i in 0..level {
|
||||
let next = *pts.get(i + 1)?;
|
||||
let cur = pts.get_mut(i)?;
|
||||
*cur = cur.lerp(next, t);
|
||||
}
|
||||
}
|
||||
// Degree 0: a point.
|
||||
Some((pts.first()?.point()?, Vec2::ZERO))
|
||||
}
|
||||
|
||||
/// Cubic pieces through the curve `bez` from `t0` to `t1`, each matching its ends and their
|
||||
/// tangents, split until within `tol` of the curve.
|
||||
fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> Option<()> {
|
||||
let (p0, d0) = eval(bez, t0)?;
|
||||
let (p3, d3) = eval(bez, t1)?;
|
||||
let h = (t1 - t0) / 3.0;
|
||||
let (c1, c2) = (p0 + d0 * h, p3 - d3 * h);
|
||||
let cubic = kurbo::CubicBez::new(p0, c1, c2, p3);
|
||||
let close = (1..8).all(|i| {
|
||||
let s = f64::from(i) / 8.0;
|
||||
eval(bez, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tol)
|
||||
});
|
||||
if close || depth >= MAX_DEPTH || bp.elements().len() >= MAX_ELEMENTS {
|
||||
if [c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
bp.curve_to(c1, c2, p3);
|
||||
} else {
|
||||
bp.line_to(p3);
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
let mid = (t0 + t1) / 2.0;
|
||||
fit(bp, bez, t0, mid, tol, depth + 1)?;
|
||||
fit(bp, bez, mid, t1, tol, depth + 1)
|
||||
}
|
||||
|
||||
/// A smooth curve through `pts` (a spline given by fit points only): Catmull-Rom tangents.
|
||||
pub(crate) fn through(pts: &[Point], closed: bool) -> Option<BezPath> {
|
||||
let n = pts.len();
|
||||
let first = *pts.first()?;
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let at = |i: isize| -> Point {
|
||||
let len = n as isize;
|
||||
let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) };
|
||||
pts.get(j as usize).copied().unwrap_or(first)
|
||||
};
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first);
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||||
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// `bp`'s bounds, when it has any finite extent.
|
||||
pub(crate) fn bounds(bp: &BezPath) -> Option<vectorcraft_geom::Rect> {
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let r = bp.bounding_box();
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kurbo::PathEl;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The largest distance from `centre` minus `r` over points along `bp`.
|
||||
fn radial_error(bp: &BezPath, centre: Point, r: f64) -> f64 {
|
||||
let mut worst: f64 = 0.0;
|
||||
for seg in bp.segments() {
|
||||
for i in 0..=32 {
|
||||
let p = kurbo::ParamCurve::eval(&seg, f64::from(i) / 32.0);
|
||||
worst = worst.max((p.distance(centre) - r).abs());
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulge_of_one_is_a_half_circle_on_the_right_of_its_chord() {
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: 1.0 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
assert!(radial_error(&bp, Point::new(5.0, 0.0), 5.0) < 1e-4);
|
||||
// Counter-clockwise from (0, 0) to (10, 0) passes below the chord.
|
||||
let b = bp.bounding_box();
|
||||
assert!((b.y0 + 5.0).abs() < 1e-3 && b.y1.abs() < 1e-9, "{b:?}");
|
||||
// A negative bulge turns the other way; a small one is a shallow arc.
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: -0.25 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
// Sagitta = bulge · chord / 2; radius = chord (1 + bulge²) / (4 |bulge|).
|
||||
let b = bp.bounding_box();
|
||||
assert!((b.y1 - 1.25).abs() < 1e-4 && b.y0.abs() < 1e-9, "{b:?}");
|
||||
let r = 10.0 * (1.0 + 0.0625) / 1.0;
|
||||
assert!(radial_error(&bp, Point::new(5.0, 1.25 - r), r) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulge_over_one_is_the_major_arc() {
|
||||
let b = (3.0 * std::f64::consts::PI / 8.0).tan(); // a 270° arc
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: b }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
let r = 10.0 / (2.0 * (135f64).to_radians().sin());
|
||||
// Counter-clockwise, so the centre and most of the circle lie right of the chord.
|
||||
let centre = Point::new(5.0, -5.0);
|
||||
assert!(radial_error(&bp, centre, r) < 1e-4);
|
||||
assert!(bp.bounding_box().y0 < -12.0, "most of the circle lies below the chord");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rational_quadratic_circle_stays_on_the_circle() {
|
||||
// A whole circle of radius 100 as nine weighted control points (four quarter arcs).
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let ctrl = [(1., 0.), (1., 1.), (0., 1.), (-1., 1.), (-1., 0.), (-1., -1.), (0., -1.), (1., -1.), (1., 0.)]
|
||||
.map(|(x, y)| Point::new(x * 100.0, y * 100.0));
|
||||
let weights = [1.0, w, 1.0, w, 1.0, w, 1.0, w, 1.0];
|
||||
let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0];
|
||||
let bp = nurbs(2, &knots, &ctrl, Some(&weights)).unwrap();
|
||||
let err = radial_error(&bp, Point::ZERO, 100.0);
|
||||
assert!(err < 0.01, "error {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polynomial_splines_become_exact_beziers() {
|
||||
// A clamped cubic with one interior knot: two cubic pieces meeting at its blossom.
|
||||
let ctrl = [(0., 0.), (10., 20.), (30., 20.), (40., 0.), (50., -10.)].map(|(x, y)| Point::new(x, y));
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0];
|
||||
let bp = nurbs(3, &knots, &ctrl, None).unwrap();
|
||||
assert_eq!(bp.elements().iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2);
|
||||
assert!(matches!(bp.elements().first(), Some(PathEl::MoveTo(p)) if *p == Point::ZERO));
|
||||
let end = match bp.elements().last() {
|
||||
Some(PathEl::CurveTo(_, _, p)) => *p,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(end, Point::new(50.0, -10.0));
|
||||
// Bad knot vectors are refused rather than read.
|
||||
assert!(nurbs(3, &knots[1..], &ctrl, None).is_none());
|
||||
assert!(nurbs(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 2.0, 2.0, 2.0], &ctrl, None).is_none());
|
||||
assert!(nurbs(0, &knots, &ctrl, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_high_degree_spline_is_fitted_within_tolerance() {
|
||||
// Degree 5 Bézier (one span) of a known polynomial: compare with direct evaluation.
|
||||
let ctrl: Vec<Point> = (0..6).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect();
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
|
||||
let bp = nurbs(5, &knots, &ctrl, None).unwrap();
|
||||
let hs: Vec<H> = ctrl.iter().map(|p| H { x: p.x, y: p.y, w: 1.0 }).collect();
|
||||
for i in 0..=20 {
|
||||
let (p, _) = eval(&hs, f64::from(i) / 20.0).unwrap();
|
||||
let near = bp.segments().map(|s| kurbo::ParamCurveNearest::nearest(&s, p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min);
|
||||
assert!(near < 0.01, "error {near}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_points_make_a_smooth_curve_through_them() {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)];
|
||||
let bp = through(&pts, false).unwrap();
|
||||
let ends: Vec<Point> = bp.elements().iter().filter_map(|e| if let PathEl::CurveTo(_, _, p) = e { Some(*p) } else { None }).collect();
|
||||
assert_eq!(ends, [Point::new(10.0, 10.0), Point::new(20.0, 0.0)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
//! Entities to drawing items: their geometry in world coordinates (drawing units, y up) and the
|
||||
//! properties they take from their layer or block (resolved when the items become art).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, PathEl};
|
||||
use vectorcraft_doc::Justify;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::curve::{self, Vertex};
|
||||
use super::reader::{Groups, Pair, Record, groups, number};
|
||||
use crate::aci::aci_rgb;
|
||||
|
||||
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
|
||||
const DESCENT: f64 = 0.3;
|
||||
/// Baseline-to-baseline distance of multiline text at line spacing 1, in cap heights.
|
||||
const LINE_SPACING: f64 = 5.0 / 3.0;
|
||||
/// The most copies one array insert (MINSERT) makes.
|
||||
const MAX_CELLS: i64 = 10_000;
|
||||
/// Art farther than this from the origin (drawing units) is damaged data, left out.
|
||||
const MAX_COORD: f64 = 1e12;
|
||||
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
|
||||
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
|
||||
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
|
||||
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
|
||||
match u8::try_from(i) {
|
||||
Ok(7) | Err(_) => [0; 3],
|
||||
Ok(i) => aci_rgb(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
|
||||
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
|
||||
let v = g.int(420)?;
|
||||
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
|
||||
Some([r, gr, b])
|
||||
}
|
||||
|
||||
/// An entity's colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Col {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Rgb([u8; 3]),
|
||||
}
|
||||
|
||||
/// An entity's lineweight.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Lw {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
/// Hundredths of a millimetre.
|
||||
Hundredths(i32),
|
||||
}
|
||||
|
||||
/// An entity's opacity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Alpha {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Opacity(f32),
|
||||
}
|
||||
|
||||
/// What an entity takes from its layer or block unless it sets it itself.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Props {
|
||||
pub layer: String,
|
||||
pub color: Col,
|
||||
pub lineweight: Lw,
|
||||
/// The linetype name in upper case (`BYLAYER`, `BYBLOCK`, `CONTINUOUS` or a table entry).
|
||||
pub linetype: String,
|
||||
pub ltscale: f64,
|
||||
pub alpha: Alpha,
|
||||
}
|
||||
|
||||
impl Props {
|
||||
fn read(g: &[Pair<'_>]) -> Self {
|
||||
let color = match (true_color(g), g.int(62)) {
|
||||
(Some(rgb), _) => Col::Rgb(rgb),
|
||||
(None, Some(0)) => Col::ByBlock,
|
||||
(None, None | Some(256..)) => Col::ByLayer,
|
||||
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
|
||||
};
|
||||
let lineweight = match g.int(370) {
|
||||
Some(-2) => Lw::ByBlock,
|
||||
Some(-3) => Lw::Default,
|
||||
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
|
||||
_ => Lw::ByLayer,
|
||||
};
|
||||
let alpha = match g.int(440) {
|
||||
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
|
||||
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
|
||||
_ => Alpha::ByLayer,
|
||||
};
|
||||
Self {
|
||||
layer: g.name(8).unwrap_or("0").to_string(),
|
||||
color,
|
||||
lineweight,
|
||||
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TextItem {
|
||||
pub text: String,
|
||||
/// Cap height, in drawing units.
|
||||
pub height: f64,
|
||||
/// Text space (the first baseline's start at the origin, x along it, y up; drawing units) to
|
||||
/// world coordinates.
|
||||
pub xf: Affine,
|
||||
/// Width factor (1: as designed).
|
||||
pub width: f64,
|
||||
pub justify: Justify,
|
||||
/// The text style (upper case).
|
||||
pub style: String,
|
||||
/// A font family the text names itself (multiline text's font changes).
|
||||
pub family: Option<String>,
|
||||
/// Baseline-to-baseline distance (drawing units), for several lines.
|
||||
pub leading: Option<f64>,
|
||||
/// The distance aligned and fit text run along their baseline.
|
||||
pub span: Option<Span>,
|
||||
}
|
||||
|
||||
/// How text fills the distance between its two points (drawing units).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Span {
|
||||
/// Stretched or squeezed across, its height kept.
|
||||
Fit(f64),
|
||||
/// Scaled as a whole, height included.
|
||||
Aligned(f64),
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
|
||||
/// either length is degenerate).
|
||||
pub fn scale(self, natural: f64) -> Option<f64> {
|
||||
let (Self::Fit(d) | Self::Aligned(d)) = self;
|
||||
let k = d / natural;
|
||||
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A block reference: one transform per copy (an array insert makes several), each from the
|
||||
/// block's coordinates (less its base point) to world coordinates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct InsertItem {
|
||||
/// The block (upper case).
|
||||
pub block: String,
|
||||
pub cells: Vec<Affine>,
|
||||
/// Its attributes' text.
|
||||
pub attribs: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Geom {
|
||||
/// A path in world coordinates; `fill`: a filled area (hatch, solid), else a line of
|
||||
/// `width` drawing units (0: the lineweight).
|
||||
Path {
|
||||
bp: BezPath,
|
||||
fill: bool,
|
||||
width: f64,
|
||||
},
|
||||
Text(TextItem),
|
||||
Insert(InsertItem),
|
||||
}
|
||||
|
||||
/// An entity as art: its geometry and its properties.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Item {
|
||||
pub props: Props,
|
||||
pub geom: Geom,
|
||||
}
|
||||
|
||||
/// Turns entity records into items, noting what it leaves out.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Converter {
|
||||
/// Entity types left out, with how many.
|
||||
pub skipped: BTreeMap<String, usize>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
|
||||
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
|
||||
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
|
||||
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
|
||||
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
|
||||
|
||||
impl Converter {
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn skip(&mut self, kind: &str) {
|
||||
*self.skipped.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
/// The items of `records` (an entity section or a block), in drawing order.
|
||||
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for group in groups(records) {
|
||||
let Some(head) = group.first() else { continue };
|
||||
// Invisible entities.
|
||||
if head.g.int(60) == Some(1) {
|
||||
continue;
|
||||
}
|
||||
match self.geom(head, group) {
|
||||
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
|
||||
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
|
||||
None if is_known(head.kind) => {}
|
||||
None => self.skip(head.kind),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
|
||||
let g = head.g;
|
||||
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
match head.kind {
|
||||
"LINE" => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(g.point(10));
|
||||
bp.line_to(g.point(11));
|
||||
path(bp)
|
||||
}
|
||||
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
|
||||
"ARC" => {
|
||||
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
|
||||
let sweep = ccw_sweep(start, end);
|
||||
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
|
||||
}
|
||||
"ELLIPSE" => path(ellipse(g)?),
|
||||
"LWPOLYLINE" => lwpolyline(g),
|
||||
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
|
||||
"SPLINE" => path(spline(g)?),
|
||||
"SOLID" | "TRACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
// The corners zigzag: the outline runs 1, 2, 4, 3.
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
|
||||
}
|
||||
"3DFACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
path(polygon(&[a, b, c, d]))
|
||||
}
|
||||
"HATCH" => self.hatch(g),
|
||||
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
|
||||
"MTEXT" => mtext(g).map(Geom::Text),
|
||||
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
|
||||
// A dimension draws the anonymous block of its lines, arrows and text.
|
||||
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let flags = g.int_or(70, 0);
|
||||
if flags & (16 | 64) != 0 {
|
||||
self.warn(MESHES);
|
||||
return None;
|
||||
}
|
||||
// Spline frame control points and polyface faces aren't on the line.
|
||||
let verts: Vec<Vertex> = rest
|
||||
.iter()
|
||||
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
|
||||
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
|
||||
.collect();
|
||||
let bp = curve::polyline(&verts, flags & 1 != 0)?;
|
||||
// 3D polylines are in world coordinates; 2D ones in their plane's.
|
||||
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
|
||||
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
|
||||
}
|
||||
|
||||
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let block = g.name(2)?.to_uppercase();
|
||||
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
|
||||
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
|
||||
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
|
||||
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
|
||||
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
|
||||
self.warn(BIG_ARRAY);
|
||||
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
|
||||
} else {
|
||||
(cols, rows)
|
||||
};
|
||||
let cells = (0..rows)
|
||||
.flat_map(|r| (0..cols).map(move |c| (r, c)))
|
||||
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
|
||||
.collect();
|
||||
let attribs = self.items(rest);
|
||||
Some(Geom::Insert(InsertItem { block, cells, attribs }))
|
||||
}
|
||||
|
||||
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let start = g.iter().position(|p| p.code == 91)?;
|
||||
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
|
||||
let mut c = Cursor { g, i: start + 1 };
|
||||
let mut bp = BezPath::new();
|
||||
for _ in 0..count {
|
||||
let Some(path) = boundary(&mut c) else { break };
|
||||
bp.extend(path);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = transformed(bp, ocs(g));
|
||||
let solid = g.int_or(70, 0) == 1;
|
||||
if g.int_or(450, 0) == 1 {
|
||||
self.warn(GRADIENT_HATCH);
|
||||
} else if !solid {
|
||||
self.warn(PATTERN_HATCH);
|
||||
return Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
}
|
||||
Some(Geom::Path { bp, fill: true, width: 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the geometry within [`MAX_COORD`] of the origin?
|
||||
fn in_range(g: &Geom) -> bool {
|
||||
let near = |p: Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
|
||||
let origin = |m: &Affine| near(m.translation().to_point());
|
||||
match g {
|
||||
Geom::Path { bp, width, .. } => {
|
||||
curve::bounds(bp).is_some_and(|b| near(Point::new(b.x0, b.y0)) && near(Point::new(b.x1, b.y1))) && *width <= MAX_COORD
|
||||
}
|
||||
Geom::Text(t) => origin(&t.xf) && t.height <= MAX_COORD,
|
||||
Geom::Insert(i) => i.cells.iter().all(|m| origin(m) && m.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity types that are read but draw nothing themselves (or were reported when read).
|
||||
fn is_known(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"LINE"
|
||||
| "CIRCLE"
|
||||
| "ARC"
|
||||
| "ELLIPSE"
|
||||
| "LWPOLYLINE"
|
||||
| "POLYLINE"
|
||||
| "SPLINE"
|
||||
| "SOLID"
|
||||
| "TRACE"
|
||||
| "3DFACE"
|
||||
| "HATCH"
|
||||
| "TEXT"
|
||||
| "ATTRIB"
|
||||
| "MTEXT"
|
||||
| "INSERT"
|
||||
| "DIMENSION"
|
||||
| "ATTDEF"
|
||||
| "SEQEND"
|
||||
| "VERTEX"
|
||||
)
|
||||
}
|
||||
|
||||
/// The sweep from `start` to `end` degrees counter-clockwise, in (0, 360].
|
||||
fn ccw_sweep(start: f64, end: f64) -> f64 {
|
||||
let s = (end - start).rem_euclid(360.0);
|
||||
if s < 1e-9 { 360.0 } else { s }
|
||||
}
|
||||
|
||||
fn transformed(mut bp: BezPath, m: Affine) -> BezPath {
|
||||
if m != Affine::IDENTITY {
|
||||
bp.apply_affine(m);
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
fn polygon(pts: &[Point]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
|
||||
}
|
||||
bp.close_path();
|
||||
bp
|
||||
}
|
||||
|
||||
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
|
||||
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
|
||||
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
|
||||
}
|
||||
|
||||
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]]
|
||||
}
|
||||
|
||||
fn unit(v: [f64; 3]) -> [f64; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len > 1e-12 { v.map(|x| x / len) } else { v }
|
||||
}
|
||||
|
||||
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
|
||||
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
|
||||
/// mirrors x).
|
||||
fn ocs(g: &[Pair<'_>]) -> Affine {
|
||||
let n = extrusion(g);
|
||||
if n[2] > 1.0 - 1e-12 {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
|
||||
let ay = unit(cross(n, ax));
|
||||
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
|
||||
}
|
||||
|
||||
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let centre = g.point(10);
|
||||
let major = g.point(11).to_vec2();
|
||||
let ratio = g.num_or(40, 1.0);
|
||||
if ratio <= 0.0 || major.hypot() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// The minor axis is the major one turned a quarter about the extrusion direction.
|
||||
let n = extrusion(g);
|
||||
let m = cross(n, [major.x, major.y, 0.0]);
|
||||
let minor = Vec2::new(m[0], m[1]) * ratio;
|
||||
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
|
||||
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
|
||||
}
|
||||
|
||||
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
let (mut widths, mut uniform) = (None, true);
|
||||
for p in g {
|
||||
match p.code {
|
||||
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
|
||||
20 => verts.last_mut()?.p.y = number(p.value)?,
|
||||
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
|
||||
40 | 41 => {
|
||||
let w = number(p.value).unwrap_or(0.0);
|
||||
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
|
||||
widths = Some(w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
|
||||
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
|
||||
Some(Geom::Path { bp, fill: false, width })
|
||||
}
|
||||
|
||||
/// The points at `code` (x) and `code + 10` (y), in order.
|
||||
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
|
||||
let mut out: Vec<Point> = Vec::new();
|
||||
for p in g {
|
||||
if p.code == code {
|
||||
out.push(Point::new(number(p.value)?, 0.0));
|
||||
} else if p.code == code + 10 {
|
||||
out.last_mut()?.y = number(p.value)?;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let flags = g.int_or(70, 0);
|
||||
let ctrl = points(g, 10)?;
|
||||
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
struct Cursor<'g, 'a> {
|
||||
g: &'g [Pair<'a>],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
/// The next group's value when it has `code` (consumed).
|
||||
fn opt(&mut self, code: i32) -> Option<&str> {
|
||||
let p = self.g.get(self.i).filter(|p| p.code == code)?;
|
||||
self.i += 1;
|
||||
Some(p.value)
|
||||
}
|
||||
fn num(&mut self, code: i32) -> Option<f64> {
|
||||
self.opt(code).and_then(number)
|
||||
}
|
||||
fn count(&mut self, code: i32) -> Option<usize> {
|
||||
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
|
||||
}
|
||||
fn point(&mut self, code: i32) -> Option<Point> {
|
||||
Some(Point::new(self.num(code)?, self.num(code + 10)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// One hatch boundary loop, closed.
|
||||
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
let flags = c.num(92)? as i64;
|
||||
let mut bp = BezPath::new();
|
||||
if flags & 2 != 0 {
|
||||
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
|
||||
let _closed = c.opt(73);
|
||||
let n = c.count(93)?;
|
||||
let mut verts = Vec::with_capacity(n.min(4096));
|
||||
for _ in 0..n {
|
||||
let p = c.point(10)?;
|
||||
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
|
||||
verts.push(Vertex { p, bulge });
|
||||
}
|
||||
bp = curve::polyline(&verts, true)?;
|
||||
} else {
|
||||
let n = c.count(93)?;
|
||||
for _ in 0..n {
|
||||
let edge = edge(c)?;
|
||||
append(&mut bp, &edge);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
// The boundary objects it was made from.
|
||||
if let Some(n) = c.count(97) {
|
||||
for _ in 0..n {
|
||||
c.opt(330);
|
||||
}
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// One edge of a hatch boundary.
|
||||
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
match c.num(72)? as i64 {
|
||||
1 => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(c.point(10)?);
|
||||
bp.line_to(c.point(11)?);
|
||||
Some(bp)
|
||||
}
|
||||
2 => {
|
||||
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let sweep = ccw_sweep(start, end);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
|
||||
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
3 => {
|
||||
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(start, end).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
|
||||
}
|
||||
4 => {
|
||||
let degree = c.count(94)?;
|
||||
let rational = c.num(73).unwrap_or(0.0) != 0.0;
|
||||
let _periodic = c.opt(74);
|
||||
let (nk, nc) = (c.count(95)?, c.count(96)?);
|
||||
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
|
||||
let mut ctrl = Vec::with_capacity(nc.min(4096));
|
||||
let mut weights = Vec::with_capacity(nc.min(4096));
|
||||
for _ in 0..nc {
|
||||
ctrl.push(c.point(10)?);
|
||||
weights.push(c.num(42).unwrap_or(1.0));
|
||||
}
|
||||
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
|
||||
// and the 97 that may follow is the boundary's count of source objects (then their
|
||||
// handles), so it is only the fit count when it is 0 or points follow.
|
||||
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
|
||||
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
|
||||
_ => false,
|
||||
};
|
||||
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
|
||||
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
// Start and end tangents.
|
||||
for code in [12, 13] {
|
||||
c.point(code);
|
||||
}
|
||||
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `seg` continuing `bp`: joined by a line when it starts elsewhere.
|
||||
fn append(bp: &mut BezPath, seg: &BezPath) {
|
||||
for el in seg.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
let here = bp.elements().last().and_then(|e| e.end_point());
|
||||
match here {
|
||||
None => bp.move_to(p),
|
||||
Some(q) if q.distance(p) > 1e-9 => bp.line_to(p),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
PathEl::ClosePath => {}
|
||||
other => bp.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline offset of vertical alignment `v` (0 baseline, 1 bottom, 2 middle, 3 top).
|
||||
fn baseline(v: i64, height: f64) -> f64 {
|
||||
match v {
|
||||
1 => height * DESCENT,
|
||||
2 => -height / 2.0,
|
||||
3 => -height,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
if attrib && g.int_or(70, 0) & 1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let s = super::text::plain(g.raw(1)?);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
|
||||
let (p1, p2) = (g.point(10), g.point_opt(11));
|
||||
let mut angle = g.num_or(50, 0.0).to_radians();
|
||||
let mut span = None;
|
||||
// Aligned and fit text run from the first point to the second.
|
||||
let anchor = match (h, p2) {
|
||||
(3 | 5, Some(p)) => {
|
||||
angle = (p - p1).atan2();
|
||||
let d = p1.distance(p);
|
||||
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
|
||||
p1
|
||||
}
|
||||
(0, _) if v == 0 => p1,
|
||||
(_, Some(p)) => p,
|
||||
_ => p1,
|
||||
};
|
||||
let justify = match h {
|
||||
1 | 4 => Justify::Center,
|
||||
2 => Justify::Right,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
let v = if h == 4 { 2 } else { v };
|
||||
let flags = g.int_or(71, 0);
|
||||
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
|
||||
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family: None,
|
||||
leading: None,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1).
|
||||
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
|
||||
let (s, family) = super::text::mtext(&raw);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let lines = s.lines().count().max(1) as f64;
|
||||
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
|
||||
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
|
||||
// The first baseline below the attachment point: under the top, around the middle, above
|
||||
// the bottom of the lines.
|
||||
let total = height + (lines - 1.0) * leading;
|
||||
let first = match attach / 3 {
|
||||
0 => -height,
|
||||
1 => total / 2.0 - height,
|
||||
_ => (lines - 1.0) * leading,
|
||||
};
|
||||
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
|
||||
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
|
||||
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: 1.0,
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family,
|
||||
leading: Some(leading),
|
||||
span: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
|
||||
//! sections (header variables; the layer, linetype, text style and block record tables; the
|
||||
//! blocks; the entities; the layouts).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
|
||||
const MAX_PAIRS: usize = 20_000_000;
|
||||
|
||||
/// The first bytes of a binary DXF file (its format's signature).
|
||||
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
|
||||
|
||||
/// One group: its code and its value as written.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Pair<'a> {
|
||||
pub code: i32,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
/// Values of a run of pairs by group code.
|
||||
pub(crate) trait Groups<'a> {
|
||||
/// The value of the first `code`, as written.
|
||||
fn raw(&self, code: i32) -> Option<&'a str>;
|
||||
/// The first `code` as a name (trimmed, never empty).
|
||||
fn name(&self, code: i32) -> Option<&'a str> {
|
||||
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
/// The first `code` as a finite number.
|
||||
fn num(&self, code: i32) -> Option<f64> {
|
||||
self.raw(code).and_then(number)
|
||||
}
|
||||
fn num_or(&self, code: i32, default: f64) -> f64 {
|
||||
self.num(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as a whole number.
|
||||
fn int(&self, code: i32) -> Option<i64> {
|
||||
self.raw(code).and_then(integer)
|
||||
}
|
||||
fn int_or(&self, code: i32, default: i64) -> i64 {
|
||||
self.int(code).unwrap_or(default)
|
||||
}
|
||||
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
|
||||
fn point(&self, code: i32) -> Point {
|
||||
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
|
||||
}
|
||||
/// [`Groups::point`] when its x is given.
|
||||
fn point_opt(&self, code: i32) -> Option<Point> {
|
||||
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Groups<'a> for [Pair<'a>] {
|
||||
fn raw(&self, code: i32) -> Option<&'a str> {
|
||||
self.iter().find(|p| p.code == code).map(|p| p.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite number (`1e999`, `NaN` and junk are none).
|
||||
pub(crate) fn number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A whole number; some writers put integers as `1.0`.
|
||||
pub(crate) fn integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
|
||||
}
|
||||
|
||||
/// One object: its type (the value of its `0` group) and the groups up to the next object.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Record<'a> {
|
||||
pub kind: &'a str,
|
||||
pub g: &'a [Pair<'a>],
|
||||
}
|
||||
|
||||
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
|
||||
/// write (a byte order mark is dropped).
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of the Windows Western code page.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
|
||||
/// reading: what came before is kept and the second value says where the file is damaged.
|
||||
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut damaged = None;
|
||||
while let Some((n, code)) = lines.next() {
|
||||
let c = code.trim();
|
||||
if c.is_empty() && out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(code) = c.parse::<i32>() else {
|
||||
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
|
||||
break;
|
||||
};
|
||||
let value = lines.next().map_or("", |(_, v)| v);
|
||||
if code == 0 && value.trim() == "EOF" {
|
||||
break;
|
||||
}
|
||||
if out.len() >= MAX_PAIRS {
|
||||
return Err("the drawing is too large to open".into());
|
||||
}
|
||||
out.push(Pair { code, value });
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((out, damaged))
|
||||
}
|
||||
|
||||
/// The objects in `pairs`: each from a `0` group up to the next.
|
||||
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, p) in pairs.iter().enumerate() {
|
||||
if p.code == 0 {
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, i));
|
||||
}
|
||||
start = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, pairs.len()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
|
||||
let kind = pairs.get(start)?.value.trim();
|
||||
Some(Record { kind, g: pairs.get(start + 1..end)? })
|
||||
}
|
||||
|
||||
/// The header variables the import reads.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Header {
|
||||
/// `$ACADVER` (`AC1032`…).
|
||||
pub acadver: String,
|
||||
/// `$INSUNITS`: the drawing unit (0: unitless).
|
||||
pub insunits: i64,
|
||||
/// `$MEASUREMENT`: 0 imperial, 1 metric.
|
||||
pub measurement: Option<i64>,
|
||||
/// `$LTSCALE`: the global linetype scale.
|
||||
pub ltscale: f64,
|
||||
/// `$LWDEFAULT`: the default lineweight (hundredths of a millimetre).
|
||||
pub lwdefault: i32,
|
||||
}
|
||||
|
||||
/// A layer of the LAYER table.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LayerDef {
|
||||
pub name: String,
|
||||
pub rgb: [u8; 3],
|
||||
/// Switched off (a negative colour) or frozen.
|
||||
pub hidden: bool,
|
||||
pub locked: bool,
|
||||
pub plot: bool,
|
||||
/// Its linetype (upper case).
|
||||
pub linetype: String,
|
||||
/// Hundredths of a millimetre; negative: the default.
|
||||
pub lineweight: i32,
|
||||
}
|
||||
|
||||
/// A block definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BlockDef<'a> {
|
||||
pub name: String,
|
||||
pub base: Point,
|
||||
pub records: Vec<Record<'a>>,
|
||||
/// A reference to another drawing (its art isn't in this file).
|
||||
pub xref: bool,
|
||||
}
|
||||
|
||||
/// A layout: model space, or a paper layout with the block holding its art.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LayoutDef {
|
||||
pub name: String,
|
||||
/// The block of a paper layout (upper case); `None` for model space.
|
||||
pub block: Option<String>,
|
||||
}
|
||||
|
||||
/// The name of model space.
|
||||
pub(crate) const MODEL: &str = "Model";
|
||||
const MODEL_BLOCK: &str = "*MODEL_SPACE";
|
||||
const PAPER_BLOCK: &str = "*PAPER_SPACE";
|
||||
|
||||
/// A drawing as the import reads it.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Drawing<'a> {
|
||||
pub header: Header,
|
||||
pub layers: Vec<LayerDef>,
|
||||
/// Index into `layers` by upper-case name.
|
||||
layer_index: HashMap<String, usize>,
|
||||
/// Linetype patterns by upper-case name: dash (positive), gap (negative), dot (0) lengths.
|
||||
pub linetypes: HashMap<String, Vec<f64>>,
|
||||
/// The font family of each text style (upper-case name; empty: a CAD font).
|
||||
pub styles: HashMap<String, String>,
|
||||
/// Blocks by upper-case name.
|
||||
pub blocks: HashMap<String, BlockDef<'a>>,
|
||||
/// The ENTITIES section.
|
||||
pub entities: Vec<Record<'a>>,
|
||||
/// Model space first, then the paper layouts in tab order.
|
||||
pub layouts: Vec<LayoutDef>,
|
||||
/// How many sections the file has (none: not a drawing).
|
||||
pub sections: usize,
|
||||
}
|
||||
|
||||
impl<'a> Drawing<'a> {
|
||||
/// Read the sections of `pairs`.
|
||||
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
|
||||
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
|
||||
let all = records(pairs);
|
||||
let mut section: Option<&str> = None;
|
||||
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
|
||||
for r in all {
|
||||
match (r.kind, section) {
|
||||
("SECTION", _) => {
|
||||
d.sections += 1;
|
||||
section = r.g.name(2);
|
||||
if section == Some("HEADER") {
|
||||
d.header = header(r.g);
|
||||
}
|
||||
}
|
||||
("ENDSEC", _) => section = None,
|
||||
(_, Some("TABLES")) => tables.push(r),
|
||||
(_, Some("BLOCKS")) => blocks.push(r),
|
||||
(_, Some("ENTITIES")) => d.entities.push(r),
|
||||
(_, Some("OBJECTS")) => objects.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut block_records = HashMap::new();
|
||||
let mut table = "";
|
||||
for r in &tables {
|
||||
match r.kind {
|
||||
"TABLE" => table = r.g.name(2).unwrap_or(""),
|
||||
"LAYER" if table == "LAYER" => {
|
||||
if let Some(l) = layer(r.g) {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
"LTYPE" if table == "LTYPE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
|
||||
d.linetypes.insert(name.to_uppercase(), pattern);
|
||||
}
|
||||
}
|
||||
"STYLE" if table == "STYLE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
d.styles.insert(name.to_uppercase(), font_family(r.g));
|
||||
}
|
||||
}
|
||||
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
|
||||
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
|
||||
block_records.insert(h.to_uppercase(), name.to_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.read_blocks(&blocks, &mut block_records);
|
||||
d.layouts = layouts(&objects, &block_records, &d.entities);
|
||||
d
|
||||
}
|
||||
|
||||
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
|
||||
let mut current: Option<BlockDef<'a>> = None;
|
||||
for r in records {
|
||||
match r.kind {
|
||||
"BLOCK" => {
|
||||
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
|
||||
// R13+ blocks name their block record: layouts find their block through it.
|
||||
if let Some(owner) = r.g.name(330) {
|
||||
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
|
||||
}
|
||||
let xref = r.g.int_or(70, 0) & 4 != 0;
|
||||
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
|
||||
}
|
||||
"ENDBLK" => {
|
||||
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
|
||||
self.blocks.insert(b.name.to_uppercase(), b);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = &mut current {
|
||||
b.records.push(*r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout named `name` (any case; `model` is model space).
|
||||
pub fn layout(&self, name: &str) -> Option<&LayoutDef> {
|
||||
self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The entity records of `layout`.
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
|
||||
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
|
||||
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
|
||||
let (block_name, paper) = match &layout.block {
|
||||
None => (MODEL_BLOCK, false),
|
||||
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
|
||||
Some(b) => return block(b),
|
||||
};
|
||||
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
|
||||
out.extend(block(block_name));
|
||||
out
|
||||
}
|
||||
|
||||
/// The layer named `name` (any case).
|
||||
pub fn layer(&self, name: &str) -> Option<&LayerDef> {
|
||||
self.layer_index.get(&name.to_uppercase()).and_then(|i| self.layers.get(*i))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is an entity (its first record) in paper space?
|
||||
fn in_paper_space(group: &[Record<'_>]) -> bool {
|
||||
group.first().is_some_and(|r| r.g.int(67) == Some(1))
|
||||
}
|
||||
|
||||
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
|
||||
/// an insert's attributes, and the SEQEND closing them).
|
||||
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
|
||||
let mut i = 0;
|
||||
std::iter::from_fn(move || {
|
||||
let start = i;
|
||||
let head = records.get(start)?;
|
||||
i += 1;
|
||||
if matches!(head.kind, "POLYLINE" | "INSERT") {
|
||||
while let Some(r) = records.get(i) {
|
||||
match r.kind {
|
||||
"VERTEX" | "ATTRIB" => i += 1,
|
||||
"SEQEND" => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
records.get(start..i)
|
||||
})
|
||||
}
|
||||
|
||||
fn header(g: &[Pair<'_>]) -> Header {
|
||||
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
|
||||
let mut var = "";
|
||||
for p in g {
|
||||
if p.code == 9 {
|
||||
var = p.value.trim();
|
||||
continue;
|
||||
}
|
||||
match (var, p.code) {
|
||||
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
|
||||
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
|
||||
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
|
||||
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
|
||||
let name = g.name(2)?.to_string();
|
||||
let aci = g.int_or(62, 7);
|
||||
let flags = g.int_or(70, 0);
|
||||
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
|
||||
Some(LayerDef {
|
||||
// CAD apps never plot this layer of dimension points.
|
||||
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
|
||||
name,
|
||||
rgb,
|
||||
hidden: aci < 0 || flags & 1 != 0,
|
||||
locked: flags & 4 != 0,
|
||||
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
|
||||
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text style's font family: the one 2000-and-later files name, else the font file's name
|
||||
/// (TrueType); CAD fonts (`.shx`) have none.
|
||||
fn font_family(g: &[Pair<'_>]) -> String {
|
||||
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
|
||||
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
|
||||
{
|
||||
return f.to_string();
|
||||
}
|
||||
let file = g.name(3).unwrap_or("");
|
||||
let path = std::path::Path::new(file);
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
|
||||
let mut paper: Vec<(i64, LayoutDef)> = vec![];
|
||||
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
|
||||
// The layout's own groups follow its subclass marker.
|
||||
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
|
||||
let Some(name) = own.name(1) else { continue };
|
||||
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned();
|
||||
if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
let Some(block) = block else { continue };
|
||||
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
|
||||
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
|
||||
}
|
||||
}
|
||||
paper.sort_by_key(|(order, _)| *order);
|
||||
if paper.is_empty() && groups(entities).any(in_paper_space) {
|
||||
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
|
||||
}
|
||||
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers,
|
||||
//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export
|
||||
//! wrote read back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::ParamCurve;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A DXF file from `(code, value)` pairs written one per line.
|
||||
fn file(pairs: &[(i32, &str)]) -> Vec<u8> {
|
||||
let mut s = String::new();
|
||||
for (c, v) in pairs {
|
||||
s.push_str(&format!("{c:>3}\n{v}\n"));
|
||||
}
|
||||
s.push_str(" 0\nEOF\n");
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs).
|
||||
fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec<u8> {
|
||||
let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")];
|
||||
p.extend_from_slice(header);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]);
|
||||
p.extend_from_slice(tables);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]);
|
||||
p.extend_from_slice(blocks);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]);
|
||||
p.extend_from_slice(entities);
|
||||
p.push((0, "ENDSEC"));
|
||||
file(&p)
|
||||
}
|
||||
|
||||
fn entities(e: &[(i32, &str)]) -> Vec<u8> {
|
||||
drawing(&[], &[], &[], e)
|
||||
}
|
||||
|
||||
/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y).
|
||||
fn points() -> ImportOptions {
|
||||
ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], o: &ImportOptions) -> Imported {
|
||||
import(bytes, o).unwrap()
|
||||
}
|
||||
|
||||
/// Every leaf object, in paint order.
|
||||
fn leaves(doc: &Document) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
out.push(n.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The artboard's height (document y = height − drawing y).
|
||||
fn height(doc: &Document) -> f64 {
|
||||
doc.artboards[0].rect.height()
|
||||
}
|
||||
|
||||
fn path_points(n: &Node) -> Vec<Point> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) };
|
||||
path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
n.geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect, tol: f64) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn rgba(p: Option<&Paint>) -> [u8; 4] {
|
||||
match p {
|
||||
Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0),
|
||||
other => panic!("not a solid paint: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_rgb(n: &Node) -> [u8; 4] {
|
||||
rgba(n.appearance.stroke().map(|s| &s.paint))
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> [u8; 4] {
|
||||
rgba(n.appearance.fill().map(|f| &f.paint))
|
||||
}
|
||||
|
||||
fn text_of(n: &Node) -> &TextObject {
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) };
|
||||
t
|
||||
}
|
||||
|
||||
const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")];
|
||||
|
||||
#[test]
|
||||
fn a_line_comes_in_at_the_drawings_unit_and_origin() {
|
||||
let doc = open(&entities(LINE), &points()).document;
|
||||
let [line] = &leaves(&doc)[..] else { panic!() };
|
||||
let board = doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size");
|
||||
// Not centred: the origin sits on the artboard's bottom-left corner.
|
||||
let pts = path_points(line);
|
||||
assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}");
|
||||
assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9);
|
||||
assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper");
|
||||
let w = line.appearance.stroke().unwrap().width;
|
||||
assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}");
|
||||
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_choose_the_default_scale() {
|
||||
// Millimetres at 1:1: 100 units are 100 mm.
|
||||
let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE);
|
||||
let doc = open(&mm, &ImportOptions::default()).document;
|
||||
assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||||
assert_eq!(doc.units, Unit::Millimeters);
|
||||
// Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches.
|
||||
let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6);
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() };
|
||||
let cm = open(&mm, &o).document;
|
||||
assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||||
assert_eq!(cm.units, Unit::Centimeters);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_arcs_and_ellipses() {
|
||||
let e = [
|
||||
(0, "CIRCLE"),
|
||||
(10, "50"),
|
||||
(20, "50"),
|
||||
(40, "25"),
|
||||
(0, "ARC"),
|
||||
(10, "200"),
|
||||
(20, "50"),
|
||||
(40, "20"),
|
||||
(50, "0"),
|
||||
(51, "90"),
|
||||
(0, "ELLIPSE"),
|
||||
(10, "400"),
|
||||
(20, "50"),
|
||||
(11, "40"),
|
||||
(21, "0"),
|
||||
(40, "0.5"),
|
||||
(41, "0"),
|
||||
(42, "6.283185307179586"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let h = height(&doc);
|
||||
let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0);
|
||||
let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() };
|
||||
assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle));
|
||||
for p in path_points(circle) {
|
||||
assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4);
|
||||
}
|
||||
// A quarter arc counter-clockwise from 0° to 90°.
|
||||
assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc));
|
||||
assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse));
|
||||
let NodeKind::Path { path, .. } = &circle.kind else { panic!() };
|
||||
assert!(path.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_bulges_are_exact_arcs() {
|
||||
// A slot: two straight sides and two half circles (bulge 1) of radius 10.
|
||||
let e = [
|
||||
(0, "LWPOLYLINE"),
|
||||
(90, "4"),
|
||||
(70, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
(42, "1"),
|
||||
(10, "100"),
|
||||
(20, "20"),
|
||||
(10, "0"),
|
||||
(20, "20"),
|
||||
(42, "1"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [slot] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
// The arcs bulge outward, so the art is 120 wide.
|
||||
assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot));
|
||||
for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) {
|
||||
let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs();
|
||||
assert!(err < 1e-4, "bulge error {err}");
|
||||
}
|
||||
// Old-style polylines read their vertices and bulges too.
|
||||
let e = [
|
||||
(0, "POLYLINE"),
|
||||
(66, "1"),
|
||||
(70, "0"),
|
||||
(0, "VERTEX"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(42, "-1"),
|
||||
(0, "VERTEX"),
|
||||
(10, "20"),
|
||||
(20, "0"),
|
||||
(0, "SEQEND"),
|
||||
(0, "LINE"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "0"),
|
||||
(21, "-30"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [arc, _] = &leaves(&doc)[..] else { panic!() };
|
||||
// Clockwise from (0, 0) to (20, 0): over the chord, 10 high.
|
||||
let b = bounds(arc);
|
||||
assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}");
|
||||
assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nurbs_splines_stay_within_a_hundredth() {
|
||||
// A rational quadratic circle of radius 100: nine weighted control points.
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2.to_string();
|
||||
let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())];
|
||||
for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] {
|
||||
e.push((40, k.into()));
|
||||
}
|
||||
for i in 0..9 {
|
||||
e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() }));
|
||||
}
|
||||
for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] {
|
||||
e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]);
|
||||
}
|
||||
let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [circle] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max);
|
||||
assert!(worst < 0.01, "NURBS error {worst}");
|
||||
// A spline given by fit points alone passes through them.
|
||||
let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [s] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
|
||||
}
|
||||
|
||||
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
|
||||
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
|
||||
vec![
|
||||
(0, "HATCH"),
|
||||
(62, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(30, "0"),
|
||||
(2, if solid == "1" { "SOLID" } else { "ANSI31" }),
|
||||
(70, solid),
|
||||
(71, "0"),
|
||||
(91, "2"),
|
||||
(92, "3"),
|
||||
(72, "0"),
|
||||
(73, "1"),
|
||||
(93, "4"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "100"),
|
||||
(10, "0"),
|
||||
(20, "100"),
|
||||
(97, "0"),
|
||||
(92, "0"),
|
||||
(93, "1"),
|
||||
(72, "2"),
|
||||
(10, "50"),
|
||||
(20, "50"),
|
||||
(40, "20"),
|
||||
(50, "0"),
|
||||
(51, "360"),
|
||||
(73, "1"),
|
||||
(97, "0"),
|
||||
(75, "0"),
|
||||
(76, "1"),
|
||||
(98, "0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_hatches_fill_and_pattern_hatches_outline() {
|
||||
let r = open(&entities(&hatch("1")), &points());
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let compound = r.document.layers[0].children().unwrap()[0].clone();
|
||||
let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) };
|
||||
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop");
|
||||
assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]);
|
||||
assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none()));
|
||||
assert_eq!(bounds(&compound).area(), 10_000.0);
|
||||
let r = open(&entities(&hatch("0")), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings);
|
||||
let outline = r.document.layers[0].children().unwrap()[0].clone();
|
||||
assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]);
|
||||
// Solids fill their corners in zigzag order.
|
||||
let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [square] = &leaves(&doc)[..] else { panic!() };
|
||||
assert_eq!(bounds(square).area(), 100.0);
|
||||
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
|
||||
}
|
||||
|
||||
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
|
||||
/// `source` (the loop's source-object count and handles), and a square hole.
|
||||
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
|
||||
let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")];
|
||||
e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]);
|
||||
e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]);
|
||||
e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k)));
|
||||
e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]);
|
||||
e.extend_from_slice(fit);
|
||||
e.extend_from_slice(source);
|
||||
e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]);
|
||||
e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]);
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_spline_edges_with_or_without_fit_data() {
|
||||
// Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own
|
||||
// count of source objects; from 2010 on it has a fit count, here 0, before that.
|
||||
let source = [(97, "1"), (330, "1F")];
|
||||
for fit in [&[][..], &[(97, "0")][..]] {
|
||||
let r = open(&entities(&spline_edged_hatch(fit, &source)), &points());
|
||||
let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") };
|
||||
let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) };
|
||||
assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}");
|
||||
// The spline edge peaks three quarters of the way up its control polygon.
|
||||
let b = bounds(hatch);
|
||||
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
// Fit points after the count are still read (and ignored when there are control points).
|
||||
let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")];
|
||||
let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points());
|
||||
assert_eq!(r.document.layers[0].children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_multiline_text() {
|
||||
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "Room %%c20"),
|
||||
(50, "90"),
|
||||
(7, "Notes"),
|
||||
(0, "MTEXT"),
|
||||
(10, "100"),
|
||||
(20, "100"),
|
||||
(40, "3.5"),
|
||||
(71, "2"),
|
||||
(3, "{\\fArial|b0;First}"),
|
||||
(1, "\\PSecond"),
|
||||
];
|
||||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||||
let [single, multi] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
let t = text_of(single);
|
||||
assert_eq!(t.plain_text(), "Room ⌀20");
|
||||
let st = t.first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size);
|
||||
assert_eq!(st.font_family, "DejaVuSans");
|
||||
// Turned a quarter counter-clockwise, its baseline starting at the insertion point.
|
||||
let origin = t.xf * Point::ZERO;
|
||||
assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}");
|
||||
let along = t.xf * Point::new(1.0, 0.0) - origin;
|
||||
assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}");
|
||||
let m = text_of(multi);
|
||||
assert_eq!(m.plain_text(), "First\nSecond");
|
||||
assert_eq!(m.para.justify, Justify::Center, "attached top centre");
|
||||
assert_eq!(m.first_style().font_family, "Arial");
|
||||
// Top attachment: the first baseline one cap height below the insertion point.
|
||||
let o = m.xf * Point::ZERO;
|
||||
assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}");
|
||||
let leading = m.first_style().leading.unwrap();
|
||||
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
|
||||
}
|
||||
|
||||
/// Where the first line's baseline starts and ends, in document coordinates.
|
||||
fn baseline_ends(t: &TextObject) -> (Point, Point) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = &layout.lines[0];
|
||||
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_span_their_two_points() {
|
||||
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
|
||||
// text left-justified for its natural size.
|
||||
let e = [
|
||||
(0, "LINE"),
|
||||
(62, "1"),
|
||||
(10, "10"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "60"),
|
||||
(0, "LINE"),
|
||||
(62, "1"),
|
||||
(10, "90"),
|
||||
(20, "0"),
|
||||
(11, "90"),
|
||||
(21, "60"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "40"),
|
||||
(40, "5"),
|
||||
(1, "FIT BETWEEN"),
|
||||
(72, "5"),
|
||||
(11, "90"),
|
||||
(21, "40"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "5"),
|
||||
(41, "0.8"),
|
||||
(1, "ALIGNED"),
|
||||
(72, "3"),
|
||||
(11, "90"),
|
||||
(21, "10"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "25"),
|
||||
(40, "5"),
|
||||
(1, "FIT BETWEEN"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "50"),
|
||||
(40, "5"),
|
||||
(41, "0.8"),
|
||||
(1, "ALIGNED"),
|
||||
];
|
||||
let o = ImportOptions { center: false, ..ImportOptions::default() };
|
||||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||||
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
|
||||
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
|
||||
for n in [fit, aligned] {
|
||||
let (a, b) = baseline_ends(text_of(n));
|
||||
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
|
||||
assert!((a.y - b.y).abs() < 1e-9, "level");
|
||||
}
|
||||
// Fit keeps its height and stretches across.
|
||||
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
|
||||
assert_eq!(st.size, natural.size);
|
||||
assert!(st.h_scale > 100.0, "{}", st.h_scale);
|
||||
// Aligned grows as a whole, its width factor kept.
|
||||
let (a, b) = baseline_ends(text_of(aligned_natural));
|
||||
let k = (x90 - x10) / (b.x - a.x);
|
||||
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
|
||||
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
|
||||
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
|
||||
for h in ["3", "5"] {
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "Gap"),
|
||||
(72, h),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "30"),
|
||||
(40, "7"),
|
||||
(1, "Gap"),
|
||||
(72, h),
|
||||
(11, "10"),
|
||||
(21, "30"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let top = height(&doc);
|
||||
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
|
||||
let t = text_of(n);
|
||||
let st = t.first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||||
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_scale_within_limits() {
|
||||
// A span a billion units long and one a millionth of a unit long.
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(40, "7"),
|
||||
(1, "X"),
|
||||
(72, "5"),
|
||||
(11, "1e9"),
|
||||
(21, "0"),
|
||||
(0, "TEXT"),
|
||||
(10, "0"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "X"),
|
||||
(72, "3"),
|
||||
(11, "1e-6"),
|
||||
(21, "10"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
|
||||
let st = text_of(fit).first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
|
||||
let st = text_of(aligned).first_style();
|
||||
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_attributes_span_their_two_points() {
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(66, "1"),
|
||||
(2, "Door"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "10"),
|
||||
(20, "20"),
|
||||
(40, "7"),
|
||||
(1, "D-01"),
|
||||
(2, "TAG"),
|
||||
(70, "0"),
|
||||
(72, "5"),
|
||||
(74, "0"),
|
||||
(11, "90"),
|
||||
(21, "20"),
|
||||
(0, "SEQEND"),
|
||||
];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
|
||||
let (a, b) = baseline_ends(text_of(&tag));
|
||||
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
|
||||
assert_eq!(text_of(&tag).first_style().size, 10.0);
|
||||
}
|
||||
|
||||
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
|
||||
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
|
||||
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
|
||||
blocks.extend_from_slice(block_art);
|
||||
blocks.push((0, "ENDBLK"));
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")];
|
||||
drawing(&[], &tables, &blocks, inserts)
|
||||
}
|
||||
|
||||
/// A red line 10 units along x from the base point, on layer 0.
|
||||
const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
|
||||
#[test]
|
||||
fn inserts_become_symbol_instances_with_their_transform() {
|
||||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
assert_eq!(doc.symbols[0].name, "Door");
|
||||
let [inst] = &leaves(&doc)[..] else { panic!() };
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) };
|
||||
assert_eq!(symbol, "Door");
|
||||
assert_eq!(doc.layers[0].name.as_deref(), Some("Doors"));
|
||||
// Scaled 2 and turned a quarter: from the insertion point 20 units up.
|
||||
let art = &doc.symbols[0].art;
|
||||
let pts: Vec<Point> = path_points(art).iter().map(|p| *xf * *p).collect();
|
||||
let h = height(&doc);
|
||||
let (a, b) = (pts.first().unwrap(), pts.last().unwrap());
|
||||
assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}");
|
||||
assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}");
|
||||
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_block_colours_make_a_symbol_per_look() {
|
||||
// The block's line takes the insert's colour: red and green make two symbols; a third red
|
||||
// insert reuses the first.
|
||||
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "3"),
|
||||
(10, "50"),
|
||||
(20, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "1"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
];
|
||||
let doc = open(&with_door(by_block, &inserts), &points()).document;
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Door", "Door 2"]);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]);
|
||||
assert_eq!(leaves(&doc).len(), 3);
|
||||
// Layer 0 art takes the insert's layer's colour.
|
||||
let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")];
|
||||
let doc = open(&with_door(on_zero, &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue");
|
||||
// Art with its own colour shares one symbol, whatever the inserts' colours.
|
||||
let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")];
|
||||
let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_inserts_attributes_and_dimensions() {
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(66, "1"),
|
||||
(2, "Door"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(70, "3"),
|
||||
(71, "2"),
|
||||
(44, "20"),
|
||||
(45, "30"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "0"),
|
||||
(20, "-10"),
|
||||
(40, "2"),
|
||||
(1, "D-01"),
|
||||
(2, "TAG"),
|
||||
(70, "0"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "0"),
|
||||
(20, "-20"),
|
||||
(40, "2"),
|
||||
(1, "hidden"),
|
||||
(2, "NOTE"),
|
||||
(70, "1"),
|
||||
(0, "SEQEND"),
|
||||
];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
let all = leaves(&doc);
|
||||
let instances: Vec<Point> =
|
||||
all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect();
|
||||
assert_eq!(instances.len(), 6, "3 columns × 2 rows");
|
||||
let xs: std::collections::BTreeSet<i64> = instances.iter().map(|p| p.x.round() as i64).collect();
|
||||
assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}");
|
||||
let texts: Vec<String> = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["D-01"], "invisible attributes stay out");
|
||||
// A dimension draws its anonymous block as art, not a symbol.
|
||||
let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")];
|
||||
let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")];
|
||||
let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document;
|
||||
assert!(doc.symbols.is_empty());
|
||||
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_inside_themselves_are_left_out_without_hanging() {
|
||||
let blocks = [
|
||||
(0, "BLOCK"),
|
||||
(2, "Loop"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "LINE"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Loop"),
|
||||
(10, "20"),
|
||||
(20, "0"),
|
||||
(0, "ENDBLK"),
|
||||
];
|
||||
let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings);
|
||||
assert_eq!(r.document.symbols.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_keep_their_state_colour_lineweight_and_linetype() {
|
||||
let tables = [
|
||||
(0, "TABLE"),
|
||||
(2, "LTYPE"),
|
||||
(0, "LTYPE"),
|
||||
(2, "DASHED"),
|
||||
(73, "2"),
|
||||
(40, "15"),
|
||||
(49, "10"),
|
||||
(49, "-5"),
|
||||
(0, "ENDTAB"),
|
||||
(0, "TABLE"),
|
||||
(2, "LAYER"),
|
||||
(0, "LAYER"),
|
||||
(2, "Hidden"),
|
||||
(70, "0"),
|
||||
(62, "-3"),
|
||||
(6, "DASHED"),
|
||||
(370, "50"),
|
||||
(0, "LAYER"),
|
||||
(2, "Frozen"),
|
||||
(70, "1"),
|
||||
(62, "2"),
|
||||
(0, "LAYER"),
|
||||
(2, "Locked"),
|
||||
(70, "4"),
|
||||
(62, "1"),
|
||||
(420, "16744448"),
|
||||
(290, "0"),
|
||||
(0, "ENDTAB"),
|
||||
];
|
||||
let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
|
||||
let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat();
|
||||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||||
let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect();
|
||||
assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]);
|
||||
let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() };
|
||||
assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible);
|
||||
assert!(locked.locked && !hidden.locked);
|
||||
assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. }));
|
||||
let first = |l: &Arc<Node>| l.children().unwrap()[0].clone();
|
||||
// Colour 3 (green) and a 0.5 mm dashed line from the layer.
|
||||
let h = first(hidden);
|
||||
assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]);
|
||||
let st = h.appearance.stroke().unwrap();
|
||||
assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9);
|
||||
assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]);
|
||||
assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]);
|
||||
assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index");
|
||||
// Merge Layers: one layer.
|
||||
let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document;
|
||||
assert_eq!(merged.layers.len(), 1);
|
||||
assert_eq!(merged.layers[0].children().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_colours_lineweights_and_transparency() {
|
||||
let e = [
|
||||
(0, "LINE"),
|
||||
(62, "30"),
|
||||
(370, "100"),
|
||||
(440, "33554559"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "LINE"),
|
||||
(62, "5"),
|
||||
(420, "65280"),
|
||||
(370, "0"),
|
||||
(10, "0"),
|
||||
(20, "10"),
|
||||
(11, "10"),
|
||||
(21, "10"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [a, b] = &leaves(&doc)[..] else { panic!() };
|
||||
assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange");
|
||||
assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm");
|
||||
assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6);
|
||||
assert_eq!(stroke_rgb(b), [0, 255, 0, 255]);
|
||||
assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line");
|
||||
// Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too.
|
||||
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() };
|
||||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||||
let w = leaves(&doc)[0].appearance.stroke().unwrap().width;
|
||||
let k = 0.1 / (72.0 / 25.4);
|
||||
assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_centre_place_the_art_on_the_artboard() {
|
||||
let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document;
|
||||
let board = doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art");
|
||||
let b = bounds(&leaves(&doc)[0]);
|
||||
assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}");
|
||||
let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() };
|
||||
let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]);
|
||||
assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}");
|
||||
// Centred at a ratio, the artboard is the art's bounds.
|
||||
let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document;
|
||||
assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9));
|
||||
// Art too large for the canvas at the scale asked for says so.
|
||||
let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() };
|
||||
let e = import(&entities(LINE), &o).unwrap_err();
|
||||
assert!(e.contains("canvas"), "{e}");
|
||||
let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err();
|
||||
assert!(e.contains("scale"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_layouts() {
|
||||
let mut e = LINE.to_vec();
|
||||
e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]);
|
||||
let bytes = entities(&e);
|
||||
assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]);
|
||||
let model = leaves(&open(&bytes, &points()).document);
|
||||
assert_eq!(model.len(), 1);
|
||||
assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line");
|
||||
let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document);
|
||||
assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle");
|
||||
let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err();
|
||||
assert!(e.contains("Model, Layout1"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_dwg_damaged_and_foreign_files() {
|
||||
let mut binary = BINARY_SENTINEL.to_vec();
|
||||
binary.extend([0u8; 40]);
|
||||
assert!(is_dxf(&binary));
|
||||
let e = import(&binary, &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("DWG"), "{e}");
|
||||
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
|
||||
assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}");
|
||||
assert!(!is_dxf(junk), "{junk:?}");
|
||||
}
|
||||
assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n"));
|
||||
// A file cut off in the middle keeps what came before.
|
||||
let mut cut = entities(LINE);
|
||||
cut.truncate(cut.len() - 30);
|
||||
cut.extend(b"\nnot a code\n");
|
||||
let r = open(&cut, &points());
|
||||
assert_eq!(leaves(&r.document).len(), 1);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings);
|
||||
// Hostile numbers are dropped; unknown entities are listed.
|
||||
let e =
|
||||
[(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")];
|
||||
let r = open(&entities(&e), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawings_the_export_writes_read_back() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let id = doc.alloc_id();
|
||||
let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0));
|
||||
let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0);
|
||||
doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap();
|
||||
let id = doc.alloc_id();
|
||||
let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0));
|
||||
doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap();
|
||||
for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let back = open(&out.bytes, &points());
|
||||
let art = leaves(&back.document);
|
||||
// Same place on the artboard (the export's origin is the artboard's bottom-left).
|
||||
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
|
||||
let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}");
|
||||
let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap();
|
||||
assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}");
|
||||
if version >= crate::DxfVersion::R2000 {
|
||||
assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch");
|
||||
let w = stroked.appearance.stroke().unwrap().width;
|
||||
assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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")];
|
||||
let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap();
|
||||
assert_eq!(i.version, "2000");
|
||||
assert_eq!(i.units, "Meters");
|
||||
assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0));
|
||||
assert_eq!(i.layers, ["0", "Walls"]);
|
||||
assert_eq!(i.layouts, ["Model"]);
|
||||
assert!(info(b"not a drawing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_extruded_along_minus_z_are_mirrored() {
|
||||
let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let b = bounds(&leaves(&doc)[0]);
|
||||
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! CAD text values as plain text: control codes (`%%d`), Unicode escapes (`\U+00B0`), caret
|
||||
//! escapes (`^I`) and, in multiline text, the formatting codes (`\P` paragraph breaks, `{\fArial;…}`
|
||||
//! font changes, `\S1/2;` stacked fractions…).
|
||||
|
||||
/// A single-line text value (TEXT, ATTRIB) as plain text.
|
||||
pub(crate) fn plain(s: &str) -> String {
|
||||
percent_codes(&escapes(s))
|
||||
}
|
||||
|
||||
/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family
|
||||
/// its first font change names.
|
||||
pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
|
||||
let s = escapes(s);
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut family = None;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'{' | '}' => {}
|
||||
'\\' => match chars.next() {
|
||||
Some('P' | 'X' | 'N') => out.push('\n'),
|
||||
Some('~') => out.push('\u{A0}'),
|
||||
Some(c @ ('\\' | '{' | '}')) => out.push(c),
|
||||
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {}
|
||||
Some('S') => {
|
||||
// A stacked fraction `\Snum^den;` (or / or #): written as num/den.
|
||||
let arg = until_semicolon(&mut chars);
|
||||
out.push_str(&arg.replace(['^', '#'], "/"));
|
||||
}
|
||||
Some(f @ ('f' | 'F')) => {
|
||||
let arg = until_semicolon(&mut chars);
|
||||
if family.is_none() && f == 'f' {
|
||||
family = arg.split('|').next().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
}
|
||||
}
|
||||
// Height, colour, tracking, oblique, width, alignment, paragraph: settings with
|
||||
// an argument.
|
||||
Some('H' | 'C' | 'c' | 'T' | 'Q' | 'W' | 'A' | 'p') => {
|
||||
until_semicolon(&mut chars);
|
||||
}
|
||||
Some(other) => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
(percent_codes(&out), family)
|
||||
}
|
||||
|
||||
/// The characters up to the next `;` (dropped).
|
||||
fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
|
||||
let mut arg = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
arg.push(c);
|
||||
}
|
||||
arg
|
||||
}
|
||||
|
||||
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
|
||||
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped.
|
||||
fn escapes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let after = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
if c == '\\'
|
||||
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
|
||||
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
|
||||
{
|
||||
out.push(ch);
|
||||
rest = after.get(6..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if c == '^'
|
||||
&& let Some(next) = after.chars().next()
|
||||
{
|
||||
match next {
|
||||
' ' => out.push('^'),
|
||||
'I' => out.push('\t'),
|
||||
_ => {}
|
||||
}
|
||||
rest = after.get(next.len_utf8()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if !c.is_control() || c == '\t' {
|
||||
out.push(c);
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `%%` control codes: `%%d` degree, `%%p` plus-minus, `%%c` diameter, `%%%` percent, `%%nnn` a
|
||||
/// character code; the underline, overline and strike-through toggles (`%%u`, `%%o`, `%%k`) are
|
||||
/// dropped.
|
||||
fn percent_codes(s: &str) -> String {
|
||||
if !s.contains("%%") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("%%") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
let code = rest.get(i + 2..).unwrap_or("");
|
||||
let mut skip = 1;
|
||||
match code.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||
Some('d') => out.push('°'),
|
||||
Some('p') => out.push('±'),
|
||||
Some('c') => out.push('⌀'),
|
||||
Some('%') => out.push('%'),
|
||||
Some('u' | 'o' | 'k') => {}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let digits: String = code.chars().take(3).take_while(char::is_ascii_digit).collect();
|
||||
skip = digits.len();
|
||||
out.extend(digits.parse::<u32>().ok().and_then(char::from_u32).filter(|c| !c.is_control()));
|
||||
}
|
||||
_ => {
|
||||
out.push_str("%%");
|
||||
skip = 0;
|
||||
}
|
||||
}
|
||||
rest = code.get(skip..).unwrap_or("");
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_codes_and_escapes() {
|
||||
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
|
||||
assert_eq!(plain("caf\\U+00E9 ^ x^I^Jy"), "café ^x\ty", "`^ ` is a caret");
|
||||
assert_eq!(plain("%%065%%066"), "AB");
|
||||
assert_eq!(plain("50%% off"), "50%% off", "an unknown code stays");
|
||||
assert_eq!(plain("\\U+"), "\\U+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtext_formatting_is_dropped() {
|
||||
let (t, family) = mtext("{\\fArial|b1|i0|c0|p34;Title}\\P\\H2.5x;Line \\C1;two\\~\\S1/2;\\Pa\\\\b {\\Lunder}");
|
||||
assert_eq!(t, "Title\nLine two\u{A0}1/2\na\\b under");
|
||||
assert_eq!(family.as_deref(), Some("Arial"));
|
||||
// Carets are written "^ " (a caret before another character is a control character).
|
||||
assert_eq!(mtext("x\\S3^ 4;").0, "x3/4");
|
||||
assert_eq!(mtext("x\\S3#4;").0, "x3/4");
|
||||
assert_eq!(mtext("trailing\\").0, "trailing\\");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
|
||||
//!
|
||||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||||
//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text
|
||||
//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the
|
||||
//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
|
||||
//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`].
|
||||
//!
|
||||
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
|
||||
//! clipping) is approximated or left out, and each such loss comes back as a warning.
|
||||
//!
|
||||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
//! - `writer`: group codes, handles and the file's sections, tables and objects.
|
||||
//! - `scene`: the document walk that turns objects into entities.
|
||||
//! - `import`: the reader, entities to art, blocks to symbols.
|
||||
|
||||
mod aci;
|
||||
pub mod import;
|
||||
mod scene;
|
||||
mod writer;
|
||||
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use aci::{aci_rgb, nearest_aci};
|
||||
pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf};
|
||||
|
||||
/// How deep symbols, blocks and brush art may nest in one another (deeper art is left out).
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
|
||||
const CAP_HEIGHT: f64 = 0.7;
|
||||
|
||||
/// The `$INSUNITS` codes of the units documents use (feet and inches write feet).
|
||||
const INSUNITS: [(u8, Unit); 7] =
|
||||
[(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)];
|
||||
|
||||
/// The `$INSUNITS` code of `unit` (none for points, picas and pixels).
|
||||
fn insunits_code(unit: Unit) -> Option<u8> {
|
||||
INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c)
|
||||
}
|
||||
|
||||
/// The DXF version a file is written for (the `$ACADVER` it declares).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DxfVersion {
|
||||
R12,
|
||||
R13,
|
||||
R14,
|
||||
R2000,
|
||||
R2004,
|
||||
R2007,
|
||||
R2010,
|
||||
R2013,
|
||||
#[default]
|
||||
R2018,
|
||||
}
|
||||
|
||||
impl DxfVersion {
|
||||
/// Newest first, as version menus list them.
|
||||
pub const ALL: [Self; 9] = [Self::R2018, Self::R2013, Self::R2010, Self::R2007, Self::R2004, Self::R2000, Self::R14, Self::R13, Self::R12];
|
||||
|
||||
/// The `version` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "R12",
|
||||
Self::R13 => "R13",
|
||||
Self::R14 => "R14",
|
||||
Self::R2000 => "2000",
|
||||
Self::R2004 => "2004",
|
||||
Self::R2007 => "2007",
|
||||
Self::R2010 => "2010",
|
||||
Self::R2013 => "2013",
|
||||
Self::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// The releases that write this version (menus).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "R12",
|
||||
Self::R13 => "R13",
|
||||
Self::R14 => "R14",
|
||||
Self::R2000 => "2000–2002",
|
||||
Self::R2004 => "2004–2006",
|
||||
Self::R2007 => "2007–2009",
|
||||
Self::R2010 => "2010–2012",
|
||||
Self::R2013 => "2013–2017",
|
||||
Self::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$ACADVER` header value.
|
||||
pub fn acadver(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "AC1009",
|
||||
Self::R13 => "AC1012",
|
||||
Self::R14 => "AC1014",
|
||||
Self::R2000 => "AC1015",
|
||||
Self::R2004 => "AC1018",
|
||||
Self::R2007 => "AC1021",
|
||||
Self::R2010 => "AC1024",
|
||||
Self::R2013 => "AC1027",
|
||||
Self::R2018 => "AC1032",
|
||||
}
|
||||
}
|
||||
|
||||
/// A version by id (`2018`, `R2018`, `r12`) or `$ACADVER` (`AC1032`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
Self::ALL.into_iter().find(|v| {
|
||||
let id = v.id();
|
||||
s.eq_ignore_ascii_case(id) || s.eq_ignore_ascii_case(v.acadver()) || s.strip_prefix(['R', 'r']).is_some_and(|y| y == id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects carry handles and subclass markers; splines, hatches and images exist.
|
||||
fn handles(self) -> bool {
|
||||
self >= Self::R13
|
||||
}
|
||||
/// Lightweight polylines.
|
||||
fn lwpolyline(self) -> bool {
|
||||
self >= Self::R14
|
||||
}
|
||||
/// Lineweights, layouts, plot styles and long symbol names.
|
||||
fn r2000(self) -> bool {
|
||||
self >= Self::R2000
|
||||
}
|
||||
/// True colour and transparency.
|
||||
fn true_color(self) -> bool {
|
||||
self >= Self::R2004
|
||||
}
|
||||
/// Text is UTF-8 (earlier versions escape what isn't ASCII as `\U+XXXX`).
|
||||
fn utf8(self) -> bool {
|
||||
self >= Self::R2007
|
||||
}
|
||||
}
|
||||
|
||||
/// How many colours entities may use: indexed colours of the 256-colour palette, or true colour
|
||||
/// (with the nearest index for apps that read only the index).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ColorDepth {
|
||||
/// Indices 1–8: the seven standard colours and dark grey.
|
||||
Aci8,
|
||||
/// Indices 1–9, orange (30) and the six greys (250–255).
|
||||
Aci16,
|
||||
/// Every index 1–255.
|
||||
Aci256,
|
||||
/// 24-bit colour (DXF 2004 and later).
|
||||
#[default]
|
||||
True,
|
||||
}
|
||||
|
||||
impl ColorDepth {
|
||||
pub const ALL: [Self; 4] = [Self::Aci8, Self::Aci16, Self::Aci256, Self::True];
|
||||
|
||||
/// The `colors` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Aci8 => "8",
|
||||
Self::Aci16 => "16",
|
||||
Self::Aci256 => "256",
|
||||
Self::True => "true",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Aci8 => "8 Colors",
|
||||
Self::Aci16 => "16 Colors",
|
||||
Self::Aci256 => "256 Colors",
|
||||
Self::True => "True Colors",
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth by id (`8`, `16`, `256`, `true`; `truecolor` and `24` also read).
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
match s.as_str() {
|
||||
"truecolor" | "truecolors" | "true colors" | "24" => Some(Self::True),
|
||||
_ => Self::ALL.into_iter().find(|d| d.id() == s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file format placed images are written in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum RasterFormat {
|
||||
#[default]
|
||||
Png,
|
||||
/// No transparency: transparent pixels show white.
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl RasterFormat {
|
||||
pub const ALL: [Self; 2] = [Self::Png, Self::Jpeg];
|
||||
|
||||
/// The `rasterFormat` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "PNG",
|
||||
Self::Jpeg => "JPEG",
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension the image files get.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
/// A format by id (`png`, `jpeg`, `jpg`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
if s.eq_ignore_ascii_case("jpg") {
|
||||
return Some(Self::Jpeg);
|
||||
}
|
||||
Self::ALL.into_iter().find(|f| s.eq_ignore_ascii_case(f.id()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the export favours when DXF can't hold an object's look as it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Preserve {
|
||||
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
|
||||
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
|
||||
/// strokes become their brush art.
|
||||
#[default]
|
||||
Appearance,
|
||||
/// Type stays text; every stroke is a line with its weight and dashes.
|
||||
Editability,
|
||||
}
|
||||
|
||||
impl Preserve {
|
||||
pub const ALL: [Self; 2] = [Self::Appearance, Self::Editability];
|
||||
|
||||
/// The `preserve` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "appearance",
|
||||
Self::Editability => "editability",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "Preserve Appearance",
|
||||
Self::Editability => "Maximize Editability",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF export settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfOptions {
|
||||
pub version: DxfVersion,
|
||||
/// The unit of the art that `scale` drawing units stand for.
|
||||
pub unit: Unit,
|
||||
/// Drawing units per `unit` (1 mm = `scale` units).
|
||||
pub scale: f64,
|
||||
/// Lineweights grow with `scale` (else they keep their printed width).
|
||||
pub scale_lineweights: bool,
|
||||
pub colors: ColorDepth,
|
||||
pub raster: RasterFormat,
|
||||
pub preserve: Preserve,
|
||||
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
|
||||
pub alter_paths: bool,
|
||||
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
|
||||
pub outline_text: bool,
|
||||
/// The drawing's origin is this region's bottom-left corner (document space).
|
||||
pub region: Rect,
|
||||
/// Leave out objects entirely outside `region` (one drawing per artboard).
|
||||
pub crop: bool,
|
||||
}
|
||||
|
||||
impl Default for DxfOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: DxfVersion::default(),
|
||||
unit: Unit::Millimeters,
|
||||
scale: 1.0,
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::default(),
|
||||
raster: RasterFormat::default(),
|
||||
preserve: Preserve::default(),
|
||||
alter_paths: false,
|
||||
outline_text: false,
|
||||
region: Rect::ZERO,
|
||||
crop: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An image file a drawing links to, to be written next to it under `name`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfImage {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A written drawing.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DxfOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
/// The image files the drawing links to (by name, next to it).
|
||||
pub images: Vec<DxfImage>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The smallest and largest `scale` (drawing units per unit).
|
||||
pub const SCALE_RANGE: std::ops::RangeInclusive<f64> = 1e-6..=1e6;
|
||||
|
||||
/// Write `doc` as a DXF drawing with `opts`. Live geometry effects are applied first; template
|
||||
/// layers, guides and hidden objects are left out.
|
||||
pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> {
|
||||
if !(opts.scale.is_finite() && SCALE_RANGE.contains(&opts.scale)) {
|
||||
return Err(format!("scale must be a number from {} to {}, not {}", SCALE_RANGE.start(), SCALE_RANGE.end(), opts.scale));
|
||||
}
|
||||
let r = opts.region;
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return Err("the export region is not finite".into());
|
||||
}
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
Ok(scene::Scene::new(doc, opts).run())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,842 @@
|
||||
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
|
||||
//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a
|
||||
//! lineweight and dashes, or filled outlines where the look needs them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use kurbo::PathEl;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::aci::nearest_aci;
|
||||
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
|
||||
use crate::{CAP_HEIGHT, ColorDepth, DxfImage, DxfOptions, DxfOutput, MAX_NEST, Preserve, RasterFormat};
|
||||
|
||||
/// Curves are flattened to within this distance (points) where DXF needs straight segments
|
||||
/// (hatch boundaries, R12 polylines).
|
||||
const FLATNESS: f64 = 0.05;
|
||||
/// Longest text value older DXF versions read.
|
||||
const MAX_TEXT: usize = 250;
|
||||
/// The lineweights DXF knows (hundredths of a millimetre).
|
||||
const LINEWEIGHTS: [i32; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
|
||||
const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are written as the nearest of the 256 indexed colours";
|
||||
const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque";
|
||||
const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines";
|
||||
const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out";
|
||||
const WIDE_LINES: &str =
|
||||
"lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width";
|
||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
||||
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
||||
|
||||
/// One segment of a subpath, cubics for every curve.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Seg {
|
||||
Line(Point),
|
||||
Cubic(Point, Point, Point),
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn end(self) -> Point {
|
||||
match self {
|
||||
Seg::Line(p) | Seg::Cubic(_, _, p) => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subpath: its start, its segments and whether it closes.
|
||||
struct Sub {
|
||||
start: Point,
|
||||
segs: Vec<Seg>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
let mut cur: Option<Sub> = None;
|
||||
let mut last = Point::ZERO;
|
||||
for el in bp.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.extend(cur.replace(Sub { start: p, segs: vec![], closed: false }));
|
||||
last = p;
|
||||
continue;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(mut s) = cur.take() {
|
||||
s.closed = true;
|
||||
last = s.start;
|
||||
out.push(s);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => Seg::Line(p),
|
||||
PathEl::QuadTo(q, p) => Seg::Cubic(last + (q - last) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p),
|
||||
PathEl::CurveTo(a, b, p) => Seg::Cubic(a, b, p),
|
||||
};
|
||||
last = seg.end();
|
||||
cur.get_or_insert_with(|| Sub { start: last, segs: vec![], closed: false }).segs.push(seg);
|
||||
}
|
||||
out.extend(cur);
|
||||
out
|
||||
}
|
||||
|
||||
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
|
||||
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let push = |p: Point, cur: &mut Vec<Point>| {
|
||||
if cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
cur.push(p);
|
||||
}
|
||||
};
|
||||
kurbo::flatten(bp.iter(), FLATNESS, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
push(p, &mut cur);
|
||||
}
|
||||
PathEl::LineTo(p) => push(p, &mut cur),
|
||||
PathEl::ClosePath => out.push(std::mem::take(&mut cur)),
|
||||
_ => {}
|
||||
});
|
||||
out.push(cur);
|
||||
for poly in &mut out {
|
||||
if poly.len() > 1 && poly.first().zip(poly.last()).is_some_and(|(a, b)| a.distance(*b) <= 1e-9) {
|
||||
poly.pop();
|
||||
}
|
||||
}
|
||||
out.retain(|p| p.len() >= 2);
|
||||
out
|
||||
}
|
||||
|
||||
/// A DXF symbol name (layer, text style) from `name`: from DXF 2000 up to 255 characters
|
||||
/// without `<>/\":;?*|=`` ` ``; before, up to 31 letters, digits, `$`, `-` and `_`, in capitals.
|
||||
fn symbol_name(name: &str, long: bool) -> String {
|
||||
let s: String = if long {
|
||||
let bad = |c: char| c.is_control() || "<>/\\\":;?*|=`".contains(c);
|
||||
name.trim().chars().map(|c| if bad(c) { '_' } else { c }).take(255).collect()
|
||||
} else {
|
||||
name.trim().chars().map(|c| if c.is_ascii_alphanumeric() || "$-_".contains(c) { c.to_ascii_uppercase() } else { '_' }).take(31).collect()
|
||||
};
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters.
|
||||
fn unique(base: &str, taken: &[&str], max: usize) -> String {
|
||||
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
|
||||
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
|
||||
if free(&base) {
|
||||
return base;
|
||||
}
|
||||
(2..)
|
||||
.map(|i| {
|
||||
let suffix = format!("_{i}");
|
||||
let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect();
|
||||
format!("{keep}{suffix}")
|
||||
})
|
||||
.find(|n| free(n))
|
||||
.unwrap_or(base)
|
||||
}
|
||||
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// The layer flags art inherits from the layers around it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LayerFlags {
|
||||
off: bool,
|
||||
locked: bool,
|
||||
plot: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Scene<'a> {
|
||||
doc: &'a Document,
|
||||
o: &'a DxfOptions,
|
||||
/// Document space → drawing units (y up, origin at the region's bottom-left).
|
||||
to_dxf: Affine,
|
||||
/// Drawing units per point.
|
||||
k: f64,
|
||||
/// The entities written so far.
|
||||
e: Codes,
|
||||
next: u32,
|
||||
layers: Vec<Layer>,
|
||||
/// The DXF layer art goes on, and its flags.
|
||||
layer: String,
|
||||
flags: LayerFlags,
|
||||
linetypes: Vec<LineType>,
|
||||
/// (style name, font family).
|
||||
styles: Vec<(String, String)>,
|
||||
images: Vec<ImageDef>,
|
||||
files: Vec<DxfImage>,
|
||||
/// Image keys → index into `images` (`None`: left out).
|
||||
by_key: HashMap<String, Option<usize>>,
|
||||
warnings: Vec<String>,
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
extents: Option<[f64; 4]>,
|
||||
/// The opacity of the containers around the current object.
|
||||
opacity: f32,
|
||||
/// Symbols and brush art being written inside one another.
|
||||
nest: u32,
|
||||
}
|
||||
|
||||
impl<'a> Scene<'a> {
|
||||
pub fn new(doc: &'a Document, o: &'a DxfOptions) -> Self {
|
||||
let k = o.scale / o.unit.points();
|
||||
let r = o.region;
|
||||
Self {
|
||||
doc,
|
||||
o,
|
||||
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
|
||||
k,
|
||||
e: Codes::new(o.version),
|
||||
next: fixed::FIRST_FREE,
|
||||
layers: vec![],
|
||||
layer: "0".into(),
|
||||
flags: LayerFlags { off: false, locked: false, plot: true },
|
||||
linetypes: vec![],
|
||||
styles: vec![],
|
||||
images: vec![],
|
||||
files: vec![],
|
||||
by_key: HashMap::new(),
|
||||
warnings: vec![],
|
||||
brushes: None,
|
||||
extents: None,
|
||||
opacity: 1.0,
|
||||
nest: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(mut self) -> DxfOutput {
|
||||
if self.o.colors == ColorDepth::True && !self.o.version.true_color() {
|
||||
self.warn(NO_TRUE_COLOR);
|
||||
}
|
||||
let doc = self.doc;
|
||||
for l in &doc.layers {
|
||||
self.node(l, false);
|
||||
}
|
||||
let r = self.o.region;
|
||||
let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k];
|
||||
let one = (self.o.scale - 1.0).abs() < 1e-12;
|
||||
let insunits = if one { crate::insunits_code(self.o.unit).unwrap_or(0) } else { 0 };
|
||||
let drawing = Drawing {
|
||||
version: self.o.version,
|
||||
layers: self.layers,
|
||||
linetypes: self.linetypes,
|
||||
styles: self.styles,
|
||||
images: self.images,
|
||||
entities: self.e.into_string(),
|
||||
extents: self.extents.unwrap_or(limits),
|
||||
limits,
|
||||
insunits,
|
||||
metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters),
|
||||
next: self.next,
|
||||
};
|
||||
DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings }
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self) -> Handle {
|
||||
let h = Handle(self.next);
|
||||
self.next = self.next.saturating_add(1);
|
||||
h
|
||||
}
|
||||
|
||||
/// A document point in drawing units, counted in the extents.
|
||||
fn pt(&mut self, p: Point) -> Point {
|
||||
let q = self.to_dxf * p;
|
||||
let e = self.extents.get_or_insert([q.x, q.y, q.x, q.y]);
|
||||
*e = [e[0].min(q.x), e[1].min(q.y), e[2].max(q.x), e[3].max(q.y)];
|
||||
q
|
||||
}
|
||||
|
||||
fn node(&mut self, n: &Node, force: bool) {
|
||||
if let NodeKind::Layer { template: true, .. } = n.kind {
|
||||
return;
|
||||
}
|
||||
// Hidden layers are written, switched off.
|
||||
let layer = n.is_layer();
|
||||
if !force && !n.visible && !layer {
|
||||
return;
|
||||
}
|
||||
if self.o.crop && !layer {
|
||||
match n.visual_bounds() {
|
||||
Some(b) if !overlaps(b, self.o.region) => return,
|
||||
None if !n.is_container() => return,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.note_losses(n);
|
||||
let opacity = self.opacity;
|
||||
self.opacity *= n.opacity.clamp(0.0, 1.0);
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, printable, clip, .. } => {
|
||||
let outer = (self.layer.clone(), self.flags);
|
||||
self.flags =
|
||||
LayerFlags { off: self.flags.off || !n.visible, locked: self.flags.locked || n.locked, plot: self.flags.plot && *printable };
|
||||
self.layer = self.add_layer(n);
|
||||
self.children(children, *clip);
|
||||
(self.layer, self.flags) = outer;
|
||||
}
|
||||
NodeKind::Group { children, clip } => self.children(children, *clip),
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
if !*guide {
|
||||
self.shape(n, &path.to_bezpath(), *rule);
|
||||
}
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = BezPath::new();
|
||||
for p in children.iter().filter_map(|c| c.path_data()) {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
self.shape(n, &bp, *rule);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Image(im) => self.image(im),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
{
|
||||
let mut art = (*sym.art).clone();
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.opacity = opacity;
|
||||
}
|
||||
|
||||
/// A container's children; a clipping container's first child is its clipping path, which
|
||||
/// paints its fills below the others and its strokes over them.
|
||||
fn children(&mut self, children: &[std::sync::Arc<Node>], clip: bool) {
|
||||
let Some((first, rest)) = children.split_first().filter(|_| clip) else {
|
||||
children.iter().for_each(|c| self.node(c, false));
|
||||
return;
|
||||
};
|
||||
self.warn(CLIPPING);
|
||||
let paint = first.clip_paint();
|
||||
if let Some(f) = &paint.fill {
|
||||
self.node(f, false);
|
||||
}
|
||||
rest.iter().for_each(|c| self.node(c, false));
|
||||
if let Some(s) = &paint.stroke {
|
||||
self.node(s, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn about what DXF can't hold in `n`'s look.
|
||||
fn note_losses(&mut self, n: &Node) {
|
||||
if n.blend != vectorcraft_color::BlendMode::Normal {
|
||||
self.warn("blending modes are left out");
|
||||
}
|
||||
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
self.warn("opacity masks are left out");
|
||||
}
|
||||
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
|
||||
if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) {
|
||||
self.warn("raster effects (shadows, glows, blurs, feathering) are left out");
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF layer for document layer `n`, with a name no other layer has.
|
||||
fn add_layer(&mut self, n: &Node) -> String {
|
||||
let long = self.o.version.r2000();
|
||||
let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long);
|
||||
let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
taken.extend(["0", "Defpoints"]);
|
||||
let name = unique(&base, &taken, if long { 255 } else { 31 });
|
||||
let aci = match n.kind {
|
||||
NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256),
|
||||
_ => 7,
|
||||
};
|
||||
let f = self.flags;
|
||||
self.layers.push(Layer { name: name.clone(), aci, off: f.off, locked: f.locked, plot: f.plot });
|
||||
name
|
||||
}
|
||||
|
||||
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
|
||||
/// first solid fill.
|
||||
fn paint_rgb(&mut self, p: &Paint) -> Option<[u8; 3]> {
|
||||
let rgb = |c: &vectorcraft_color::Color| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
};
|
||||
match p {
|
||||
Paint::None => None,
|
||||
Paint::Solid { color, .. } => Some(rgb(color)),
|
||||
Paint::Gradient(g) => {
|
||||
self.warn("gradients are written as one colour, the average of their stops");
|
||||
let stops = &g.gradient.stops;
|
||||
let n = stops.len().max(1) as f64;
|
||||
let sum = stops.iter().map(|s| rgb(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i])));
|
||||
Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8))
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => {
|
||||
self.warn("pattern fills are written as one colour");
|
||||
let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a)));
|
||||
Some(first.map_or([128; 3], |c| rgb(&c)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity style of colour `rgb` at `opacity` (times the containers'), with an optional
|
||||
/// stroke weight (points) and linetype.
|
||||
fn style(&mut self, rgb: [u8; 3], opacity: f32, weight: Option<f64>, linetype: Option<String>) -> EntityStyle {
|
||||
let v = self.o.version;
|
||||
let opacity = (self.opacity * opacity).clamp(0.0, 1.0);
|
||||
let alpha = if opacity >= 0.998 {
|
||||
None
|
||||
} else if v.true_color() {
|
||||
Some((opacity * 255.0).round() as u8)
|
||||
} else {
|
||||
self.warn(NO_TRANSPARENCY);
|
||||
None
|
||||
};
|
||||
EntityStyle {
|
||||
aci: nearest_aci(rgb, self.o.colors),
|
||||
rgb: (self.o.colors == ColorDepth::True).then(|| u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]])),
|
||||
lineweight: weight.filter(|_| v.r2000()).map(|w| self.lineweight(w)),
|
||||
linetype,
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
|
||||
fn lineweight(&mut self, width: f64) -> i32 {
|
||||
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
|
||||
let hundredths = width * 2540.0 / 72.0 * scale;
|
||||
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
|
||||
if hundredths > f64::from(max) + 5.0 {
|
||||
self.warn(WIDE_LINES);
|
||||
}
|
||||
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
|
||||
}
|
||||
|
||||
/// A linetype for dash pattern `d` (one per distinct pattern).
|
||||
fn linetype(&mut self, d: &Dash) -> String {
|
||||
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
|
||||
if lengths.len() % 2 == 1 {
|
||||
lengths.extend(lengths.clone());
|
||||
}
|
||||
let pattern: Vec<f64> = lengths.iter().enumerate().map(|(i, v)| if i % 2 == 0 { *v } else { -v }).collect();
|
||||
let same = |l: &LineType| l.pattern.len() == pattern.len() && l.pattern.iter().zip(&pattern).all(|(a, b)| (a - b).abs() < 1e-9);
|
||||
if let Some(l) = self.linetypes.iter().find(|l| same(l)) {
|
||||
return l.name.clone();
|
||||
}
|
||||
let name = format!("DASHED{}", self.linetypes.len() + 1);
|
||||
self.linetypes.push(LineType { name: name.clone(), pattern });
|
||||
name
|
||||
}
|
||||
|
||||
/// A path's fills and strokes, in paint order.
|
||||
fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) {
|
||||
if bp.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
for item in &n.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if f.visible => {
|
||||
if let Some(rgb) = self.paint_rgb(&f.paint) {
|
||||
let style = self.style(rgb, f.opacity, None, None);
|
||||
self.area(bp, rule, &style, false);
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer) {
|
||||
let appearance = self.o.preserve == Preserve::Appearance;
|
||||
if appearance
|
||||
&& st.brush.is_some()
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.brush_art(bp, st)
|
||||
{
|
||||
let opacity = self.opacity;
|
||||
self.opacity *= st.opacity.clamp(0.0, 1.0);
|
||||
self.nest += 1;
|
||||
art.iter().for_each(|piece| self.node(piece, true));
|
||||
self.nest -= 1;
|
||||
self.opacity = opacity;
|
||||
return;
|
||||
}
|
||||
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
|
||||
let plain = stroke::is_plain(st);
|
||||
if self.o.alter_paths || (appearance && !plain) {
|
||||
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
|
||||
let style = self.style(rgb, st.opacity, None, None);
|
||||
self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true);
|
||||
return;
|
||||
}
|
||||
if !plain {
|
||||
self.warn(STROKE_EXTRAS);
|
||||
}
|
||||
let linetype = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| self.linetype(d));
|
||||
let style = self.style(rgb, st.opacity, Some(st.width), linetype);
|
||||
self.lines(bp, &style, st.width);
|
||||
}
|
||||
|
||||
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<Vec<Node>> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
||||
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
|
||||
}
|
||||
|
||||
/// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so
|
||||
/// the hatch's even-odd rule reads it as it is.
|
||||
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
|
||||
if !self.o.version.handles() {
|
||||
self.warn(NO_FILLS);
|
||||
self.lines(bp, style, 0.0);
|
||||
return;
|
||||
}
|
||||
let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1;
|
||||
let normalized = (rule == FillRule::NonZero && several && !clean)
|
||||
.then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok())
|
||||
.flatten()
|
||||
.map(|p| p.to_bezpath());
|
||||
let loops: Vec<Vec<Point>> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect();
|
||||
if loops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let h = self.handle();
|
||||
let layer = self.layer.clone();
|
||||
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
|
||||
self.e.xyz(10, 0.0, 0.0);
|
||||
self.e.normal();
|
||||
self.e.raw(2, "SOLID");
|
||||
self.e.int(70, 1);
|
||||
self.e.int(71, 0);
|
||||
self.e.int(91, loops.len() as i64);
|
||||
for l in &loops {
|
||||
// An external polyline boundary, closed, without bulges.
|
||||
self.e.int(92, 3);
|
||||
self.e.int(72, 0);
|
||||
self.e.int(73, 1);
|
||||
self.e.int(93, l.len() as i64);
|
||||
for p in l {
|
||||
let q = self.pt(*p);
|
||||
self.e.xy(10, q.x, q.y);
|
||||
}
|
||||
self.e.int(97, 0);
|
||||
}
|
||||
self.e.int(75, 0);
|
||||
self.e.int(76, 1);
|
||||
self.e.int(98, 0);
|
||||
}
|
||||
|
||||
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
|
||||
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
|
||||
/// polylines as a width.
|
||||
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
|
||||
let v = self.o.version;
|
||||
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
|
||||
for sub in subpaths(bp) {
|
||||
if sub.segs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if sub.segs.iter().all(|s| matches!(s, Seg::Line(_))) {
|
||||
let mut pts: Vec<Point> = std::iter::once(sub.start).chain(sub.segs.iter().map(|s| s.end())).collect();
|
||||
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(sub.start);
|
||||
for s in &sub.segs {
|
||||
match *s {
|
||||
Seg::Line(p) => path.line_to(p),
|
||||
Seg::Cubic(a, b, p) => path.curve_to(a, b, p),
|
||||
}
|
||||
}
|
||||
for pts in polygons(&path) {
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
|
||||
/// vertex entity per point.
|
||||
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
let flags = i64::from(closed);
|
||||
if self.o.version.lwpolyline() {
|
||||
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
|
||||
self.e.int(90, pts.len() as i64);
|
||||
self.e.int(70, flags);
|
||||
if let Some(w) = width {
|
||||
self.e.num(43, w);
|
||||
}
|
||||
for p in &pts {
|
||||
self.e.xy(10, p.x, p.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.e.entity("POLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDb2dPolyline");
|
||||
self.e.int(66, 1);
|
||||
self.e.xyz(10, 0.0, 0.0);
|
||||
self.e.int(70, flags);
|
||||
if let Some(w) = width {
|
||||
self.e.num(40, w);
|
||||
self.e.num(41, w);
|
||||
}
|
||||
for p in &pts {
|
||||
let vh = self.handle();
|
||||
self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex");
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDb2dVertex");
|
||||
}
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
}
|
||||
let end = self.handle();
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
let end = sub.segs.last().map_or(sub.start, |s| s.end());
|
||||
let closing = (sub.closed && end.distance(sub.start) > 1e-9).then_some(Seg::Line(sub.start));
|
||||
let mut ctrl = vec![sub.start];
|
||||
let mut last = sub.start;
|
||||
for seg in sub.segs.iter().copied().chain(closing) {
|
||||
match seg {
|
||||
Seg::Line(p) => ctrl.extend([last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p]),
|
||||
Seg::Cubic(a, b, p) => ctrl.extend([a, b, p]),
|
||||
}
|
||||
last = seg.end();
|
||||
}
|
||||
let n = (ctrl.len() - 1) / 3;
|
||||
let mut knots = vec![0.0; 4];
|
||||
for i in 1..n {
|
||||
knots.extend([i as f64; 3]);
|
||||
}
|
||||
knots.extend([n as f64; 4]);
|
||||
let ctrl: Vec<Point> = ctrl.iter().map(|p| self.pt(*p)).collect();
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline");
|
||||
self.e.normal();
|
||||
// Planar.
|
||||
self.e.int(70, 8);
|
||||
self.e.int(71, 3);
|
||||
self.e.int(72, knots.len() as i64);
|
||||
self.e.int(73, ctrl.len() as i64);
|
||||
self.e.int(74, 0);
|
||||
for k in knots {
|
||||
self.e.num(40, k);
|
||||
}
|
||||
for p in ctrl {
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
|
||||
/// entity per run of each line.
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
|
||||
if outline {
|
||||
if let Some(o) = vectorcraft_effects::outline_text(n) {
|
||||
self.node(&o, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !n.appearance.items.is_empty() {
|
||||
self.warn(TYPE_ITEMS);
|
||||
}
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let plain = t.plain_text();
|
||||
let xf = self.to_dxf * t.xf;
|
||||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||||
// Along and across the baseline, in drawing units per text-space unit.
|
||||
let along = a.hypot(b);
|
||||
if along <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let across = (a * d - b * c).abs() / along;
|
||||
let angle = b.atan2(a).to_degrees();
|
||||
for line in &layout.lines {
|
||||
let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue };
|
||||
for run in glyphs.chunk_by(|x, y| x.run == y.run) {
|
||||
let Some(first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
|
||||
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
|
||||
let Some(rgb) = self.paint_rgb(paint) else { continue };
|
||||
let style = self.style(rgb, 1.0, None, None);
|
||||
let text_style = self.text_style(&cs.font_family);
|
||||
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
|
||||
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
|
||||
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
|
||||
let p = self.pt(t.xf * origin);
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("TEXT", h, fixed::MODEL_RECORD, &layer, &style, "AcDbText");
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
self.e.num(40, height);
|
||||
let s: String = s.chars().take(MAX_TEXT).collect();
|
||||
self.e.str(1, &s);
|
||||
if angle.abs() > 1e-9 {
|
||||
self.e.num(50, angle);
|
||||
}
|
||||
if (width - 1.0).abs() > 1e-9 {
|
||||
self.e.num(41, width);
|
||||
}
|
||||
self.e.str(7, &text_style);
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbText");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text style for font `family` (one per family).
|
||||
fn text_style(&mut self, family: &str) -> String {
|
||||
if let Some((name, _)) = self.styles.iter().find(|(_, f)| f == family) {
|
||||
return name.clone();
|
||||
}
|
||||
let long = self.o.version.r2000();
|
||||
let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect();
|
||||
taken.push(crate::writer::STANDARD_STYLE);
|
||||
let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 });
|
||||
self.styles.push((name.clone(), family.to_string()));
|
||||
name
|
||||
}
|
||||
|
||||
/// A placed image, linked to its file (written once per image).
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
if !self.o.version.handles() {
|
||||
self.warn(NO_IMAGES);
|
||||
return;
|
||||
}
|
||||
let Some(i) = self.image_def(&im.key) else { return };
|
||||
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
|
||||
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
let map = self.to_dxf * im.xf;
|
||||
let lin = |v: kurbo::Vec2| (map * v.to_point()) - (map * Point::ZERO);
|
||||
// Insertion at the bottom-left corner; u along one file pixel, v one pixel up.
|
||||
let u = lin(kurbo::Vec2::new(w / dw, 0.0));
|
||||
let v = lin(kurbo::Vec2::new(0.0, -h / dh));
|
||||
for corner in [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)] {
|
||||
self.pt(im.xf * corner);
|
||||
}
|
||||
let at = map * Point::new(0.0, h);
|
||||
let handle = self.handle();
|
||||
let reactor = self.handle();
|
||||
let Some(def) = self.images.get_mut(i) else { return };
|
||||
def.reactors.push((reactor, handle));
|
||||
let def = def.handle;
|
||||
let layer = self.layer.clone();
|
||||
let style = EntityStyle { aci: 7, ..EntityStyle::default() };
|
||||
self.e.entity("IMAGE", handle, fixed::MODEL_RECORD, &layer, &style, "AcDbRasterImage");
|
||||
self.e.int(90, 0);
|
||||
self.e.xyz(10, at.x, at.y);
|
||||
self.e.xyz(11, u.x, u.y);
|
||||
self.e.xyz(12, v.x, v.y);
|
||||
self.e.xy(13, dw, dh);
|
||||
self.e.handle(340, def);
|
||||
// Shown, also when not aligned with the screen; PNG with its transparency.
|
||||
self.e.int(70, if self.o.raster == RasterFormat::Png { 11 } else { 3 });
|
||||
self.e.int(280, 0);
|
||||
self.e.int(281, 50);
|
||||
self.e.int(282, 50);
|
||||
self.e.int(283, 0);
|
||||
self.e.handle(360, reactor);
|
||||
self.e.int(71, 1);
|
||||
self.e.int(91, 2);
|
||||
self.e.xy(14, -0.5, -0.5);
|
||||
self.e.xy(14, dw - 0.5, dh - 0.5);
|
||||
if self.o.version >= crate::DxfVersion::R2010 {
|
||||
self.e.int(290, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The image file for image `key`, encoded once in the chosen format (`None`: left out).
|
||||
fn image_def(&mut self, key: &str) -> Option<usize> {
|
||||
if let Some(i) = self.by_key.get(key) {
|
||||
return *i;
|
||||
}
|
||||
let made = self.encode_image(key);
|
||||
let i = match made {
|
||||
Ok((file, size)) => {
|
||||
let handle = self.handle();
|
||||
self.images.push(ImageDef { name: file.name.clone(), size, handle, reactors: vec![] });
|
||||
self.files.push(file);
|
||||
Some(self.images.len() - 1)
|
||||
}
|
||||
Err(e) => {
|
||||
self.warn(&e);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.by_key.insert(key.to_string(), i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
|
||||
let size = (img.width(), img.height());
|
||||
let mut bytes = vec![];
|
||||
let written = match self.o.raster {
|
||||
RasterFormat::Png => img.to_rgba8().write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png),
|
||||
RasterFormat::Jpeg => {
|
||||
// No alpha in JPEG: transparent pixels over white.
|
||||
let mut rgba = img.to_rgba8();
|
||||
for p in rgba.pixels_mut() {
|
||||
let a = u32::from(p[3]);
|
||||
for c in 0..3 {
|
||||
p[c] = ((u32::from(p[c]) * a + 255 * (255 - a)) / 255) as u8;
|
||||
}
|
||||
}
|
||||
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
|
||||
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
||||
}
|
||||
};
|
||||
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
|
||||
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first visible solid fill colour in `n` (pattern art).
|
||||
fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
|
||||
let own = n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) if f.visible => f.paint.color(),
|
||||
_ => None,
|
||||
});
|
||||
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
type Pairs = Vec<(i32, String)>;
|
||||
|
||||
/// The `(code, value)` pairs of a DXF file.
|
||||
fn pairs(dxf: &[u8]) -> Pairs {
|
||||
let text = std::str::from_utf8(dxf).expect("UTF-8");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs");
|
||||
lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect()
|
||||
}
|
||||
|
||||
/// The objects of section `name`: each the pairs from its `0` code on.
|
||||
fn section(p: &[(i32, String)], name: &str) -> Vec<Pairs> {
|
||||
let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into()));
|
||||
let Some(start) = start else { return vec![] };
|
||||
let mut out: Vec<Pairs> = vec![];
|
||||
for pair in &p[start + 2..] {
|
||||
if *pair == (0, "ENDSEC".into()) {
|
||||
break;
|
||||
}
|
||||
if pair.0 == 0 {
|
||||
out.push(vec![]);
|
||||
}
|
||||
if let Some(o) = out.last_mut() {
|
||||
o.push(pair.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn kind(o: &[(i32, String)]) -> &str {
|
||||
o.first().map_or("", |p| p.1.as_str())
|
||||
}
|
||||
|
||||
fn get(o: &[(i32, String)], code: i32) -> Option<&str> {
|
||||
o.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
|
||||
}
|
||||
|
||||
fn all(o: &[(i32, String)], code: i32) -> Vec<f64> {
|
||||
o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect()
|
||||
}
|
||||
|
||||
fn header(p: &[(i32, String)], var: &str) -> Option<String> {
|
||||
let i = p.iter().position(|x| *x == (9, var.into()))?;
|
||||
p.get(i + 1).map(|x| x.1.clone())
|
||||
}
|
||||
|
||||
fn entities(out: &DxfOutput) -> Vec<Pairs> {
|
||||
section(&pairs(&out.bytes), "ENTITIES")
|
||||
}
|
||||
|
||||
fn kinds(out: &DxfOutput) -> Vec<String> {
|
||||
entities(out).iter().map(|e| kind(e).to_string()).collect()
|
||||
}
|
||||
|
||||
fn table(out: &DxfOutput, kind_: &str) -> Vec<Pairs> {
|
||||
section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect()
|
||||
}
|
||||
|
||||
/// A 200 × 100 pt document whose first layer holds what `nodes` makes.
|
||||
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let nodes = nodes(&mut d);
|
||||
let layer = d.layers[0].id;
|
||||
for n in nodes {
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node {
|
||||
Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width))
|
||||
}
|
||||
|
||||
/// The options with the first artboard as the region, in points.
|
||||
fn opts(d: &Document) -> DxfOptions {
|
||||
DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() }
|
||||
}
|
||||
|
||||
/// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse.
|
||||
fn shapes_doc() -> Document {
|
||||
doc_with(|d| {
|
||||
vec![
|
||||
path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_version_declares_its_acadver_and_is_well_formed() {
|
||||
let d = shapes_doc();
|
||||
for v in DxfVersion::ALL {
|
||||
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
|
||||
let p = pairs(&out.bytes);
|
||||
assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}");
|
||||
assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}");
|
||||
let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count();
|
||||
assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections");
|
||||
assert!(!entities(&out).is_empty(), "{v:?}");
|
||||
if v >= DxfVersion::R13 {
|
||||
// Every handle is unique and below $HANDSEED.
|
||||
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
|
||||
let handles: Vec<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
|
||||
let unique: std::collections::HashSet<_> = handles.iter().collect();
|
||||
assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles");
|
||||
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
|
||||
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
|
||||
assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary");
|
||||
} else {
|
||||
assert!(header(&p, "$HANDSEED").is_none());
|
||||
}
|
||||
}
|
||||
assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013));
|
||||
assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12));
|
||||
assert_eq!(DxfVersion::from_id("2019"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke");
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
let poly = &e[1];
|
||||
assert_eq!(get(poly, 70), Some("1"), "closed");
|
||||
let mut xs = all(poly, 10);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]);
|
||||
let mut ys = all(poly, 20);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]);
|
||||
let hatch = &e[0];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[2];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[1], 43), Some("1"));
|
||||
assert_eq!(get(&entities(&r14)[1], 370), None);
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]);
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_map_to_the_index_palette_or_true_colour() {
|
||||
let d = shapes_doc();
|
||||
let style = |o: &DxfOutput, i: usize| {
|
||||
let e = &entities(o)[i];
|
||||
(get(e, 62).map(str::to_string), get(e, 420).map(str::to_string))
|
||||
};
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
|
||||
assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7");
|
||||
assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into())));
|
||||
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
|
||||
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
|
||||
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
|
||||
}
|
||||
let orange = doc_with(|d| {
|
||||
vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)]
|
||||
});
|
||||
let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string();
|
||||
assert_eq!(
|
||||
(aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)),
|
||||
("30".into(), "30".into(), "2".into()),
|
||||
"8 colours: yellow is nearest"
|
||||
);
|
||||
// True colour needs 2004: earlier versions fall back to indices, and say so.
|
||||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||||
assert_eq!(style(&old, 0), (Some("1".into()), None));
|
||||
assert!(old.warnings.iter().any(|w| w.contains("true colour")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_scale_coordinates_and_set_insunits() {
|
||||
let d = shapes_doc();
|
||||
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
|
||||
let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max);
|
||||
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&mm).as_deref(), Some("4"));
|
||||
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
|
||||
assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1"));
|
||||
let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&inches).as_deref(), Some("1"));
|
||||
// 1 in = 10 units: unitless, coordinates ten times larger.
|
||||
let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
|
||||
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
|
||||
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
|
||||
assert_eq!(get(&entities(&scaled)[1], 370), Some("35"));
|
||||
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&heavy)[1], 370), Some("70"));
|
||||
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
|
||||
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_become_dxf_layers() {
|
||||
let mut d = shapes_doc();
|
||||
let second = d.add_layer(Some("Dims: <top>"));
|
||||
let hidden = d.add_layer(Some("Notes"));
|
||||
let template = d.add_layer(Some("Tracing"));
|
||||
for l in [second, hidden, template] {
|
||||
let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
if let Some(n) = d.node_mut(hidden) {
|
||||
n.visible = false;
|
||||
}
|
||||
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
|
||||
*template = true;
|
||||
}
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let layers: Vec<(String, String)> =
|
||||
table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect();
|
||||
let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect();
|
||||
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
|
||||
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
|
||||
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
|
||||
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]);
|
||||
// Before 2000: capitals, letters, digits and $-_ only.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_become_linetypes_and_opacity_transparency() {
|
||||
let d = doc_with(|d| {
|
||||
let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||||
}
|
||||
n.opacity = 0.5;
|
||||
vec![n]
|
||||
});
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let e = &entities(&out)[0];
|
||||
let alpha = (0x0200_0000 | 128).to_string();
|
||||
assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str())));
|
||||
let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap();
|
||||
assert_eq!(all(<, 49), [6.0, -3.0]);
|
||||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||||
assert!(get(&entities(&old)[0], 440).is_none());
|
||||
assert!(old.warnings.iter().any(|w| w.contains("transparency")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]);
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[1], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
let mut d = shapes_doc();
|
||||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]);
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
fn text_doc(text: &str, size: f64) -> Document {
|
||||
doc_with(|d| {
|
||||
let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() };
|
||||
let t = TextObject::point(Point::new(10.0, 50.0), text, style);
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_text_or_outlines() {
|
||||
let d = text_doc("Plan A", 20.0);
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&editable);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A")));
|
||||
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type");
|
||||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up");
|
||||
let family = CharStyle::default().font_family;
|
||||
let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap();
|
||||
assert_eq!(get(&e[0], 7), get(&style, 2));
|
||||
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
|
||||
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
|
||||
let k = kinds(&export(&d, &o).unwrap());
|
||||
assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_ascii_is_escaped_before_2007() {
|
||||
let d = text_doc("Größe", 12.0);
|
||||
let text = |v| {
|
||||
let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
get(&entities(&out)[0], 1).unwrap().to_string()
|
||||
};
|
||||
assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe");
|
||||
assert_eq!(text(DxfVersion::R2018), "Größe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_link_png_or_jpeg_files() {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = doc_with(|d| {
|
||||
let im = ImageObject {
|
||||
key: "raster-1".into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
|
||||
});
|
||||
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "IMAGE");
|
||||
// The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units.
|
||||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0));
|
||||
assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0));
|
||||
assert_eq!(out.images.len(), 1);
|
||||
assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG"));
|
||||
let p = pairs(&out.bytes);
|
||||
let objects = section(&p, "OBJECTS");
|
||||
let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap();
|
||||
assert_eq!(get(def, 1), Some(out.images[0].name.as_str()));
|
||||
assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition");
|
||||
assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR"));
|
||||
assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
|
||||
let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap();
|
||||
assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8]));
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(entities(&r12).is_empty() && r12.images.is_empty());
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! The ASCII DXF file: group codes, handles, and the sections, tables and objects around the
|
||||
//! entities. DXF R13 and later give every object a handle and subclass markers; R2000 and later
|
||||
//! add layouts and plot styles, which CAD apps expect to find.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::DxfVersion;
|
||||
|
||||
/// An object handle (written in hex).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Handle(pub u32);
|
||||
|
||||
/// The handles of the fixed objects every drawing has. Entities and table entries count up from
|
||||
/// [`FIRST_FREE`].
|
||||
pub(crate) mod fixed {
|
||||
use super::Handle;
|
||||
pub const LAYER_TABLE: Handle = Handle(0x1);
|
||||
pub const LTYPE_TABLE: Handle = Handle(0x2);
|
||||
pub const APPID_TABLE: Handle = Handle(0x3);
|
||||
pub const DIMSTYLE_TABLE: Handle = Handle(0x4);
|
||||
pub const STYLE_TABLE: Handle = Handle(0x5);
|
||||
pub const UCS_TABLE: Handle = Handle(0x6);
|
||||
pub const VIEW_TABLE: Handle = Handle(0x7);
|
||||
pub const VPORT_TABLE: Handle = Handle(0x8);
|
||||
pub const BLOCK_RECORD_TABLE: Handle = Handle(0x9);
|
||||
pub const ROOT: Handle = Handle(0xA);
|
||||
pub const GROUPS: Handle = Handle(0xB);
|
||||
pub const LAYOUTS: Handle = Handle(0xC);
|
||||
pub const MODEL_RECORD: Handle = Handle(0xD);
|
||||
pub const PAPER_RECORD: Handle = Handle(0xE);
|
||||
pub const MODEL_BLOCK: Handle = Handle(0xF);
|
||||
pub const MODEL_END: Handle = Handle(0x10);
|
||||
pub const PAPER_BLOCK: Handle = Handle(0x11);
|
||||
pub const PAPER_END: Handle = Handle(0x12);
|
||||
pub const MODEL_LAYOUT: Handle = Handle(0x13);
|
||||
pub const PAPER_LAYOUT: Handle = Handle(0x14);
|
||||
pub const ACTIVE_VPORT: Handle = Handle(0x15);
|
||||
pub const ACAD_APPID: Handle = Handle(0x16);
|
||||
pub const STANDARD_DIMSTYLE: Handle = Handle(0x17);
|
||||
pub const IMAGE_DICT: Handle = Handle(0x18);
|
||||
pub const IMAGE_VARS: Handle = Handle(0x19);
|
||||
pub const PLOT_STYLES: Handle = Handle(0x1A);
|
||||
pub const PLOT_NORMAL: Handle = Handle(0x1B);
|
||||
pub const FIRST_FREE: u32 = 0x30;
|
||||
}
|
||||
|
||||
/// A number as DXF writes it: up to 6 decimals, no trailing zeros, 0 for anything not finite.
|
||||
pub(crate) fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let s = format!("{v:.6}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// Text as a DXF string value: one line, `^` escaped, and before DXF 2007 (which reads UTF-8)
|
||||
/// every character outside ASCII as `\U+XXXX`.
|
||||
pub(crate) fn text(s: &str, utf8: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\r' | '\n' | '\t' => out.push(' '),
|
||||
c if c.is_control() => {}
|
||||
'^' => out.push_str("^ "),
|
||||
c if !utf8 && !c.is_ascii() => match u16::try_from(u32::from(c)) {
|
||||
Ok(u) => _ = write!(out, "\\U+{u:04X}"),
|
||||
Err(_) => out.push('?'),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Group-code/value pairs in the order they are written.
|
||||
pub(crate) struct Codes {
|
||||
pub v: DxfVersion,
|
||||
out: String,
|
||||
}
|
||||
|
||||
impl Codes {
|
||||
pub fn new(v: DxfVersion) -> Self {
|
||||
Self { v, out: String::new() }
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.out
|
||||
}
|
||||
|
||||
pub fn append(&mut self, other: &str) {
|
||||
self.out.push_str(other);
|
||||
}
|
||||
|
||||
/// A value written as it is (names and keywords the writer itself chose).
|
||||
pub fn raw(&mut self, code: u16, value: &str) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{value}");
|
||||
}
|
||||
|
||||
/// Text from the document, encoded for the version (see [`text`]).
|
||||
pub fn str(&mut self, code: u16, value: &str) {
|
||||
let t = text(value, self.v.utf8());
|
||||
self.raw(code, &t);
|
||||
}
|
||||
|
||||
pub fn int(&mut self, code: u16, value: i64) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{value}");
|
||||
}
|
||||
|
||||
pub fn num(&mut self, code: u16, value: f64) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{}", num(value));
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, code: u16, h: Handle) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{:X}", h.0);
|
||||
}
|
||||
|
||||
/// A 2D point at `code` (x), `code + 10` (y).
|
||||
pub fn xy(&mut self, code: u16, x: f64, y: f64) {
|
||||
self.num(code, x);
|
||||
self.num(code + 10, y);
|
||||
}
|
||||
|
||||
/// A 3D point at `code`, `code + 10`, `code + 20` (z = 0).
|
||||
pub fn xyz(&mut self, code: u16, x: f64, y: f64) {
|
||||
self.xy(code, x, y);
|
||||
self.num(code + 20, 0.0);
|
||||
}
|
||||
|
||||
/// The extrusion direction of planar entities: the z axis.
|
||||
pub fn normal(&mut self) {
|
||||
self.num(210, 0.0);
|
||||
self.num(220, 0.0);
|
||||
self.num(230, 1.0);
|
||||
}
|
||||
|
||||
/// The start of an object: its type, and from R13 its handle, owner and first subclass.
|
||||
fn object(&mut self, kind: &str, h: Handle, owner: Handle, subclass: &str) {
|
||||
self.raw(0, kind);
|
||||
if self.v.handles() {
|
||||
self.handle(if kind == "DIMSTYLE" { 105 } else { 5 }, h);
|
||||
self.handle(330, owner);
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
|
||||
/// A table entry: the shared record subclass, then the table's own (from R13).
|
||||
fn record(&mut self, kind: &str, h: Handle, table: Handle, subclass: &str) {
|
||||
self.object(kind, h, table, "AcDbSymbolTableRecord");
|
||||
if self.v.handles() {
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
|
||||
fn section(&mut self, name: &str) {
|
||||
self.raw(0, "SECTION");
|
||||
self.raw(2, name);
|
||||
}
|
||||
|
||||
fn end_section(&mut self) {
|
||||
self.raw(0, "ENDSEC");
|
||||
}
|
||||
|
||||
fn table(&mut self, name: &str, h: Handle, count: usize) {
|
||||
self.raw(0, "TABLE");
|
||||
self.raw(2, name);
|
||||
if self.v.handles() {
|
||||
self.handle(5, h);
|
||||
self.raw(330, "0");
|
||||
self.raw(100, "AcDbSymbolTable");
|
||||
}
|
||||
self.int(70, count as i64);
|
||||
if name == "DIMSTYLE" && self.v.r2000() {
|
||||
self.raw(100, "AcDbDimStyleTable");
|
||||
}
|
||||
}
|
||||
|
||||
fn end_table(&mut self) {
|
||||
self.raw(0, "ENDTAB");
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entity is drawn: its colour index, true colour, lineweight, linetype and transparency.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct EntityStyle {
|
||||
pub aci: u8,
|
||||
/// `0xRRGGBB` (DXF 2004 and later).
|
||||
pub rgb: Option<u32>,
|
||||
/// Hundredths of a millimetre (DXF 2000 and later).
|
||||
pub lineweight: Option<i32>,
|
||||
pub linetype: Option<String>,
|
||||
/// Opacity 0–255 (DXF 2004 and later; opaque is left out).
|
||||
pub alpha: Option<u8>,
|
||||
}
|
||||
|
||||
impl Codes {
|
||||
/// The start of an entity of `kind` owned by `owner` (model space, or a polyline for its
|
||||
/// vertices) on `layer` with its style, ending with `subclass` (none when empty).
|
||||
pub fn entity(&mut self, kind: &str, h: Handle, owner: Handle, layer: &str, style: &EntityStyle, subclass: &str) {
|
||||
self.object(kind, h, owner, "AcDbEntity");
|
||||
self.str(8, layer);
|
||||
if let Some(lt) = &style.linetype {
|
||||
self.str(6, lt);
|
||||
}
|
||||
self.int(62, i64::from(style.aci.max(1)));
|
||||
if self.v.r2000()
|
||||
&& let Some(lw) = style.lineweight
|
||||
{
|
||||
self.int(370, i64::from(lw));
|
||||
}
|
||||
if self.v.true_color() {
|
||||
if let Some(rgb) = style.rgb {
|
||||
self.int(420, i64::from(rgb));
|
||||
}
|
||||
if let Some(a) = style.alpha {
|
||||
self.int(440, 0x0200_0000 | i64::from(a));
|
||||
}
|
||||
}
|
||||
if self.v.handles() && !subclass.is_empty() {
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF layer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Layer {
|
||||
pub name: String,
|
||||
pub aci: u8,
|
||||
/// Switched off (a hidden layer).
|
||||
pub off: bool,
|
||||
pub locked: bool,
|
||||
/// Plotted (printable).
|
||||
pub plot: bool,
|
||||
}
|
||||
|
||||
/// A dash pattern: dash, gap, dash… lengths in drawing units.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LineType {
|
||||
pub name: String,
|
||||
pub pattern: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A placed image file and the image entities that show it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ImageDef {
|
||||
pub name: String,
|
||||
pub size: (u32, u32),
|
||||
pub handle: Handle,
|
||||
/// `(reactor, image entity)` per image entity.
|
||||
pub reactors: Vec<(Handle, Handle)>,
|
||||
}
|
||||
|
||||
/// Everything around the entities, collected while they were written.
|
||||
pub(crate) struct Drawing {
|
||||
pub version: DxfVersion,
|
||||
pub layers: Vec<Layer>,
|
||||
pub linetypes: Vec<LineType>,
|
||||
/// Text styles: (style name, font family).
|
||||
pub styles: Vec<(String, String)>,
|
||||
pub images: Vec<ImageDef>,
|
||||
/// The entities' group codes.
|
||||
pub entities: String,
|
||||
/// `[x0, y0, x1, y1]` of everything written (drawing units).
|
||||
pub extents: [f64; 4],
|
||||
/// The export region (drawing units).
|
||||
pub limits: [f64; 4],
|
||||
/// `$INSUNITS`.
|
||||
pub insunits: u8,
|
||||
pub metric: bool,
|
||||
/// The next free handle.
|
||||
pub next: u32,
|
||||
}
|
||||
|
||||
/// The text style every drawing has.
|
||||
pub(crate) const STANDARD_STYLE: &str = "Standard";
|
||||
|
||||
impl Drawing {
|
||||
/// The whole file.
|
||||
pub fn write(self) -> String {
|
||||
let v = self.version;
|
||||
let mut c = Codes::new(v);
|
||||
// Table entries get the handles after the entities'.
|
||||
let mut next = self.next;
|
||||
let mut take = |n: usize| {
|
||||
let start = next;
|
||||
next = next.saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
|
||||
start
|
||||
};
|
||||
let ltype_base = take(3 + self.linetypes.len());
|
||||
let layer_base = take(1 + self.layers.len());
|
||||
let style_base = take(1 + self.styles.len());
|
||||
self.header(&mut c, next);
|
||||
if v.handles() {
|
||||
self.classes(&mut c);
|
||||
}
|
||||
c.section("TABLES");
|
||||
self.vport(&mut c);
|
||||
self.ltypes(&mut c, ltype_base);
|
||||
self.layer_table(&mut c, layer_base);
|
||||
self.style_table(&mut c, style_base);
|
||||
if v.handles() {
|
||||
for (name, h) in [("VIEW", fixed::VIEW_TABLE), ("UCS", fixed::UCS_TABLE)] {
|
||||
c.table(name, h, 0);
|
||||
c.end_table();
|
||||
}
|
||||
c.table("APPID", fixed::APPID_TABLE, 1);
|
||||
c.record("APPID", fixed::ACAD_APPID, fixed::APPID_TABLE, "AcDbRegAppTableRecord");
|
||||
c.raw(2, "ACAD");
|
||||
c.int(70, 0);
|
||||
c.end_table();
|
||||
c.table("DIMSTYLE", fixed::DIMSTYLE_TABLE, 1);
|
||||
c.record("DIMSTYLE", fixed::STANDARD_DIMSTYLE, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord");
|
||||
c.raw(2, "Standard");
|
||||
c.int(70, 0);
|
||||
c.end_table();
|
||||
c.table("BLOCK_RECORD", fixed::BLOCK_RECORD_TABLE, 2);
|
||||
for (name, h, layout) in
|
||||
[("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_LAYOUT), ("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_LAYOUT)]
|
||||
{
|
||||
c.record("BLOCK_RECORD", h, fixed::BLOCK_RECORD_TABLE, "AcDbBlockTableRecord");
|
||||
c.raw(2, name);
|
||||
if v.r2000() {
|
||||
c.handle(340, layout);
|
||||
c.int(70, 0);
|
||||
c.int(280, 1);
|
||||
c.int(281, 0);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
c.end_section();
|
||||
if v.handles() {
|
||||
c.section("BLOCKS");
|
||||
for (name, record, begin, end) in [
|
||||
("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_BLOCK, fixed::MODEL_END),
|
||||
("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_BLOCK, fixed::PAPER_END),
|
||||
] {
|
||||
c.object("BLOCK", begin, record, "AcDbEntity");
|
||||
c.raw(8, "0");
|
||||
c.raw(100, "AcDbBlockBegin");
|
||||
c.raw(2, name);
|
||||
c.int(70, 0);
|
||||
c.xyz(10, 0.0, 0.0);
|
||||
c.raw(3, name);
|
||||
c.raw(1, "");
|
||||
c.object("ENDBLK", end, record, "AcDbEntity");
|
||||
c.raw(8, "0");
|
||||
c.raw(100, "AcDbBlockEnd");
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
c.section("ENTITIES");
|
||||
c.append(&self.entities);
|
||||
c.end_section();
|
||||
if v.handles() {
|
||||
self.objects(&mut c);
|
||||
}
|
||||
c.raw(0, "EOF");
|
||||
c.into_string()
|
||||
}
|
||||
|
||||
fn header(&self, c: &mut Codes, handseed: u32) {
|
||||
let v = self.version;
|
||||
let var = |c: &mut Codes, name: &str| c.raw(9, name);
|
||||
c.section("HEADER");
|
||||
var(c, "$ACADVER");
|
||||
c.raw(1, v.acadver());
|
||||
var(c, "$DWGCODEPAGE");
|
||||
c.raw(3, "ANSI_1252");
|
||||
var(c, "$INSBASE");
|
||||
c.xyz(10, 0.0, 0.0);
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
var(c, "$EXTMIN");
|
||||
c.xyz(10, x0, y0);
|
||||
var(c, "$EXTMAX");
|
||||
c.xyz(10, x1, y1);
|
||||
let [lx0, ly0, lx1, ly1] = self.limits;
|
||||
var(c, "$LIMMIN");
|
||||
c.xy(10, lx0, ly0);
|
||||
var(c, "$LIMMAX");
|
||||
c.xy(10, lx1, ly1);
|
||||
if v.r2000() {
|
||||
var(c, "$LWDISPLAY");
|
||||
c.int(290, 1);
|
||||
var(c, "$INSUNITS");
|
||||
c.int(70, i64::from(self.insunits));
|
||||
var(c, "$MEASUREMENT");
|
||||
c.int(70, i64::from(self.metric));
|
||||
}
|
||||
if v.handles() {
|
||||
var(c, "$HANDSEED");
|
||||
c.handle(5, Handle(handseed));
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
fn classes(&self, c: &mut Codes) {
|
||||
let mut classes: Vec<(&str, &str, &str, i64, bool)> = vec![];
|
||||
if self.version.r2000() {
|
||||
classes.extend([
|
||||
("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", "ObjectDBX Classes", 0, false),
|
||||
("ACDBPLACEHOLDER", "AcDbPlaceHolder", "ObjectDBX Classes", 0, false),
|
||||
("LAYOUT", "AcDbLayout", "ObjectDBX Classes", 0, false),
|
||||
]);
|
||||
}
|
||||
if !self.images.is_empty() {
|
||||
classes.extend([
|
||||
("RASTERVARIABLES", "AcDbRasterVariables", "ISM", 0, false),
|
||||
("IMAGE", "AcDbRasterImage", "ISM", 2175, true),
|
||||
("IMAGEDEF", "AcDbRasterImageDef", "ISM", 0, false),
|
||||
("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", "ISM", 1, false),
|
||||
]);
|
||||
}
|
||||
c.section("CLASSES");
|
||||
for (name, cpp, app, flags, entity) in classes {
|
||||
c.raw(0, "CLASS");
|
||||
c.raw(1, name);
|
||||
c.raw(2, cpp);
|
||||
c.raw(3, app);
|
||||
c.int(90, flags);
|
||||
if self.version.true_color() {
|
||||
c.int(91, 0);
|
||||
}
|
||||
c.int(280, 0);
|
||||
c.int(281, i64::from(entity));
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
/// The active viewport, zoomed to the drawing's extents.
|
||||
fn vport(&self, c: &mut Codes) {
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
c.table("VPORT", fixed::VPORT_TABLE, 1);
|
||||
c.record("VPORT", fixed::ACTIVE_VPORT, fixed::VPORT_TABLE, "AcDbViewportTableRecord");
|
||||
c.raw(2, if self.version.handles() { "*Active" } else { "*ACTIVE" });
|
||||
c.int(70, 0);
|
||||
c.xy(10, 0.0, 0.0);
|
||||
c.xy(11, 1.0, 1.0);
|
||||
c.xy(12, (x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
c.xy(13, 0.0, 0.0);
|
||||
c.xy(14, 1.0, 1.0);
|
||||
c.xy(15, 10.0, 10.0);
|
||||
c.xyz(16, 0.0, 0.0);
|
||||
c.num(36, 1.0);
|
||||
c.xyz(17, 0.0, 0.0);
|
||||
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
|
||||
// The view's height, with a margin; its width follows from the aspect ratio.
|
||||
c.num(40, (h.max(w / 1.5) * 1.1).max(1.0));
|
||||
c.num(41, 1.5);
|
||||
c.num(42, 50.0);
|
||||
c.num(43, 0.0);
|
||||
c.num(44, 0.0);
|
||||
c.num(50, 0.0);
|
||||
c.num(51, 0.0);
|
||||
for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] {
|
||||
c.int(code, value);
|
||||
}
|
||||
if self.version.r2000() {
|
||||
c.int(281, 0);
|
||||
c.int(65, 0);
|
||||
c.num(146, 0.0);
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn ltypes(&self, c: &mut Codes, base: u32) {
|
||||
c.table("LTYPE", fixed::LTYPE_TABLE, 3 + self.linetypes.len());
|
||||
let solid = [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")];
|
||||
let rows = solid.iter().map(|(n, d)| (*n, *d, &[][..])).chain(self.linetypes.iter().map(|l| (l.name.as_str(), "", l.pattern.as_slice())));
|
||||
for (i, (name, desc, pattern)) in rows.enumerate() {
|
||||
c.record("LTYPE", Handle(base.saturating_add(i as u32)), fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord");
|
||||
c.str(2, name);
|
||||
c.int(70, 0);
|
||||
c.str(3, if pattern.is_empty() { desc } else { "Dashed" });
|
||||
c.int(72, 65);
|
||||
c.int(73, pattern.len() as i64);
|
||||
c.num(40, pattern.iter().map(|v| v.abs()).sum());
|
||||
for v in pattern {
|
||||
c.num(49, *v);
|
||||
if self.version.handles() {
|
||||
c.int(74, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn layer_table(&self, c: &mut Codes, base: u32) {
|
||||
let zero = Layer { name: "0".into(), aci: 7, off: false, locked: false, plot: true };
|
||||
c.table("LAYER", fixed::LAYER_TABLE, 1 + self.layers.len());
|
||||
for (i, l) in std::iter::once(&zero).chain(&self.layers).enumerate() {
|
||||
c.record("LAYER", Handle(base.saturating_add(i as u32)), fixed::LAYER_TABLE, "AcDbLayerTableRecord");
|
||||
c.str(2, &l.name);
|
||||
c.int(70, if l.locked { 4 } else { 0 });
|
||||
// A negative colour switches the layer off.
|
||||
let aci = i64::from(l.aci.max(1));
|
||||
c.int(62, if l.off { -aci } else { aci });
|
||||
c.raw(6, "Continuous");
|
||||
if self.version.r2000() {
|
||||
c.int(290, i64::from(l.plot));
|
||||
c.int(370, -3);
|
||||
c.handle(390, fixed::PLOT_NORMAL);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn style_table(&self, c: &mut Codes, base: u32) {
|
||||
c.table("STYLE", fixed::STYLE_TABLE, 1 + self.styles.len());
|
||||
let standard = (STANDARD_STYLE.to_string(), String::new());
|
||||
for (i, (name, family)) in std::iter::once(&standard).chain(&self.styles).enumerate() {
|
||||
c.record("STYLE", Handle(base.saturating_add(i as u32)), fixed::STYLE_TABLE, "AcDbTextStyleTableRecord");
|
||||
c.str(2, name);
|
||||
c.int(70, 0);
|
||||
c.num(40, 0.0);
|
||||
c.num(41, 1.0);
|
||||
c.num(50, 0.0);
|
||||
c.int(71, 0);
|
||||
c.num(42, 2.5);
|
||||
// The font file; from 2000 the family name too, which is how CAD apps find a TrueType font.
|
||||
if family.is_empty() {
|
||||
c.raw(3, "txt");
|
||||
} else {
|
||||
c.str(3, &format!("{family}.ttf"));
|
||||
}
|
||||
c.raw(4, "");
|
||||
if self.version.r2000() && !family.is_empty() {
|
||||
c.raw(1001, "ACAD");
|
||||
c.str(1000, family);
|
||||
c.int(1071, 0);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn objects(&self, c: &mut Codes) {
|
||||
let v = self.version;
|
||||
let images = !self.images.is_empty();
|
||||
c.section("OBJECTS");
|
||||
let dict = |c: &mut Codes, h: Handle, owner: Handle, entries: &[(&str, Handle)]| {
|
||||
c.object("DICTIONARY", h, owner, "AcDbDictionary");
|
||||
c.int(281, 1);
|
||||
for (name, e) in entries {
|
||||
c.str(3, name);
|
||||
c.handle(350, *e);
|
||||
}
|
||||
};
|
||||
let mut root = vec![("ACAD_GROUP", fixed::GROUPS)];
|
||||
if images {
|
||||
root.extend([("ACAD_IMAGE_DICT", fixed::IMAGE_DICT), ("ACAD_IMAGE_VARS", fixed::IMAGE_VARS)]);
|
||||
}
|
||||
if v.r2000() {
|
||||
root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]);
|
||||
}
|
||||
dict(c, fixed::ROOT, Handle(0), &root);
|
||||
dict(c, fixed::GROUPS, fixed::ROOT, &[]);
|
||||
if v.r2000() {
|
||||
dict(c, fixed::LAYOUTS, fixed::ROOT, &[("Layout1", fixed::PAPER_LAYOUT), ("Model", fixed::MODEL_LAYOUT)]);
|
||||
c.object("ACDBDICTIONARYWDFLT", fixed::PLOT_STYLES, fixed::ROOT, "AcDbDictionary");
|
||||
c.int(281, 1);
|
||||
c.raw(3, "Normal");
|
||||
c.handle(350, fixed::PLOT_NORMAL);
|
||||
c.raw(100, "AcDbDictionaryWithDefault");
|
||||
c.handle(340, fixed::PLOT_NORMAL);
|
||||
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
|
||||
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
|
||||
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
|
||||
}
|
||||
if images {
|
||||
c.object("RASTERVARIABLES", fixed::IMAGE_VARS, fixed::ROOT, "AcDbRasterVariables");
|
||||
c.int(90, 0);
|
||||
c.int(70, 0);
|
||||
c.int(71, 1);
|
||||
c.int(72, 0);
|
||||
let entries: Vec<(&str, Handle)> = self.images.iter().map(|i| (i.name.as_str(), i.handle)).collect();
|
||||
dict(c, fixed::IMAGE_DICT, fixed::ROOT, &entries);
|
||||
for img in &self.images {
|
||||
c.raw(0, "IMAGEDEF");
|
||||
c.handle(5, img.handle);
|
||||
c.raw(102, "{ACAD_REACTORS");
|
||||
c.handle(330, fixed::IMAGE_DICT);
|
||||
for (reactor, _) in &img.reactors {
|
||||
c.handle(330, *reactor);
|
||||
}
|
||||
c.raw(102, "}");
|
||||
c.handle(330, fixed::IMAGE_DICT);
|
||||
c.raw(100, "AcDbRasterImageDef");
|
||||
c.int(90, 0);
|
||||
c.str(1, &img.name);
|
||||
c.xy(10, f64::from(img.size.0), f64::from(img.size.1));
|
||||
c.xy(11, 1.0, 1.0);
|
||||
c.int(280, 1);
|
||||
c.int(281, 0);
|
||||
for (reactor, image) in &img.reactors {
|
||||
c.object("IMAGEDEF_REACTOR", *reactor, *image, "AcDbRasterImageDefReactor");
|
||||
c.int(90, 2);
|
||||
c.handle(330, *image);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
/// A layout (DXF 2000 and later): its plot settings, then the layout itself.
|
||||
fn layout(&self, c: &mut Codes, h: Handle, name: &str, tab: i64, record: Handle) {
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
c.object("LAYOUT", h, fixed::LAYOUTS, "AcDbPlotSettings");
|
||||
c.raw(1, "");
|
||||
c.raw(2, "none_device");
|
||||
c.raw(4, "");
|
||||
c.raw(6, "");
|
||||
for code in 40..=49 {
|
||||
c.num(code, 0.0);
|
||||
}
|
||||
c.num(140, 0.0);
|
||||
c.num(141, 0.0);
|
||||
c.num(142, 1.0);
|
||||
c.num(143, 1.0);
|
||||
c.int(70, 688);
|
||||
c.int(72, 1);
|
||||
c.int(73, 0);
|
||||
c.int(74, 5);
|
||||
c.raw(7, "");
|
||||
c.int(75, 16);
|
||||
c.num(147, 1.0);
|
||||
c.xy(148, 0.0, 0.0);
|
||||
c.raw(100, "AcDbLayout");
|
||||
c.raw(1, name);
|
||||
c.int(70, 1);
|
||||
c.int(71, tab);
|
||||
let [lx0, ly0, lx1, ly1] = self.limits;
|
||||
c.xy(10, lx0, ly0);
|
||||
c.xy(11, lx1, ly1);
|
||||
c.xyz(12, 0.0, 0.0);
|
||||
c.xyz(14, x0, y0);
|
||||
c.xyz(15, x1, y1);
|
||||
c.num(146, 0.0);
|
||||
c.xyz(13, 0.0, 0.0);
|
||||
c.xyz(16, 1.0, 0.0);
|
||||
c.xyz(17, 0.0, 1.0);
|
||||
c.int(76, 1);
|
||||
c.handle(330, record);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numbers_and_text_are_written_plainly() {
|
||||
assert_eq!(num(1.5), "1.5");
|
||||
assert_eq!(num(-0.0000001), "0");
|
||||
assert_eq!(num(2.0), "2");
|
||||
assert_eq!(num(f64::NAN), "0");
|
||||
assert_eq!(text("a^b\nc", true), "a^ b c");
|
||||
assert_eq!(text("Größe 😀", false), "Gr\\U+00F6\\U+00DFe ?");
|
||||
assert_eq!(text("Größe", true), "Größe");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ impl BlendMode {
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
/// Whether the mode works on each colour channel separately (all but Hue, Saturation, Color
|
||||
/// and Luminosity).
|
||||
pub fn is_separable(self) -> bool {
|
||||
self.group() != 5
|
||||
}
|
||||
/// Parse a label or identifier, case/space-insensitively.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
@@ -83,10 +88,225 @@ impl BlendMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Luminance weights of the non-separable modes (Hue, Saturation, Color, Luminosity), as the PDF
|
||||
/// and W3C compositing specifications define them.
|
||||
pub const LUM: [f32; 3] = [0.3, 0.59, 0.11];
|
||||
|
||||
/// Luminance weights of opacity masks (Rec. 709, applied to the sRGB values as SVG luminance masks
|
||||
/// with `color-interpolation="sRGB"` apply them).
|
||||
pub const MASK_LUM: [f32; 3] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
/// The blend function B(backdrop, source) of `mode` on straight (not premultiplied) RGB in 0..=1:
|
||||
/// the colour that replaces the backdrop where an opaque source covers an opaque backdrop. Every
|
||||
/// output (screen, SVG, PDF and transparency flattening) follows these formulas.
|
||||
pub fn blend_rgb(mode: BlendMode, backdrop: [f32; 3], source: [f32; 3]) -> [f32; 3] {
|
||||
use BlendMode as B;
|
||||
let separable = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(backdrop[i].clamp(0.0, 1.0), source[i].clamp(0.0, 1.0)));
|
||||
match mode {
|
||||
B::Normal => separable(|_, s| s),
|
||||
B::Multiply => separable(|b, s| b * s),
|
||||
B::Screen => separable(screen),
|
||||
B::Overlay => separable(|b, s| hard_light(s, b)),
|
||||
B::Darken => separable(f32::min),
|
||||
B::Lighten => separable(f32::max),
|
||||
B::ColorDodge => separable(|b, s| {
|
||||
if b <= 0.0 {
|
||||
0.0
|
||||
} else if s >= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
(b / (1.0 - s)).min(1.0)
|
||||
}
|
||||
}),
|
||||
B::ColorBurn => separable(|b, s| {
|
||||
if b >= 1.0 {
|
||||
1.0
|
||||
} else if s <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - ((1.0 - b) / s).min(1.0)
|
||||
}
|
||||
}),
|
||||
B::HardLight => separable(hard_light),
|
||||
B::SoftLight => separable(|b, s| {
|
||||
if s <= 0.5 {
|
||||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||
} else {
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
b + (2.0 * s - 1.0) * (d - b)
|
||||
}
|
||||
}),
|
||||
B::Difference => separable(|b, s| (b - s).abs()),
|
||||
B::Exclusion => separable(|b, s| b + s - 2.0 * b * s),
|
||||
B::Hue => set_lum(set_sat(source, sat(backdrop)), lum(backdrop)),
|
||||
B::Saturation => set_lum(set_sat(backdrop, sat(source)), lum(backdrop)),
|
||||
B::Color => set_lum(source, lum(backdrop)),
|
||||
B::Luminosity => set_lum(backdrop, lum(source)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Source-over compositing of `source` onto `backdrop` with `mode` (straight RGBA in 0..=1, the
|
||||
/// result straight too): the source colour is mixed with B(backdrop, source) by the backdrop's
|
||||
/// alpha, then laid over the backdrop by its own alpha.
|
||||
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] {
|
||||
let (ab, as_) = (backdrop[3].clamp(0.0, 1.0), source[3].clamp(0.0, 1.0));
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let mixed = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]);
|
||||
let c = |i: usize| ((1.0 - ab) * source[i] + ab * mixed[i]) * as_ + (1.0 - as_) * ab * backdrop[i];
|
||||
[c(0) / ao, c(1) / ao, c(2) / ao, ao]
|
||||
}
|
||||
|
||||
/// CMYK blending (CMYK documents). The formulas above work on additive values, so inks blend as
|
||||
/// their complements, as subtractive colour spaces blend in PDF: a CMYK colour splits into two
|
||||
/// additive planes, each blended with the formulas above, the complemented C, M and Y as one
|
||||
/// colour and the complemented K as a grey. Multiply then adds inks (cyan over magenta prints
|
||||
/// both: blue), and on the grey plane Hue, Saturation and Color keep the backdrop's black while
|
||||
/// Luminosity takes the source's, as PDF has it for K.
|
||||
pub fn cmyk_planes([c, m, y, k]: [f32; 4]) -> [[f32; 3]; 2] {
|
||||
[[1.0 - c, 1.0 - m, 1.0 - y], [1.0 - k; 3]]
|
||||
}
|
||||
|
||||
/// The inks of blended [`cmyk_planes`]: the C, M, Y plane and the K plane's grey.
|
||||
pub fn planes_cmyk(cmy: [f32; 3], k: f32) -> [f32; 4] {
|
||||
[1.0 - cmy[0], 1.0 - cmy[1], 1.0 - cmy[2], 1.0 - k].map(|v| v.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn screen(b: f32, s: f32) -> f32 {
|
||||
b + s - b * s
|
||||
}
|
||||
|
||||
fn hard_light(b: f32, s: f32) -> f32 {
|
||||
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
|
||||
}
|
||||
|
||||
fn lum(c: [f32; 3]) -> f32 {
|
||||
LUM[0] * c[0] + LUM[1] * c[1] + LUM[2] * c[2]
|
||||
}
|
||||
|
||||
fn sat(c: [f32; 3]) -> f32 {
|
||||
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||||
}
|
||||
|
||||
/// Bring a colour moved off the gamut by [`set_lum`] back in, keeping its luminance. A grey (all
|
||||
/// components at its luminance, give or take rounding) is clamped instead of scaled by 0/0.
|
||||
fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
||||
let l = lum(c);
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut c = c;
|
||||
if n < 0.0 {
|
||||
c = if l - n > f32::EPSILON { c.map(|v| l + (v - l) * l / (l - n)) } else { c.map(|v| v.max(0.0)) };
|
||||
}
|
||||
if x > 1.0 {
|
||||
c = if x - l > f32::EPSILON { c.map(|v| l + (v - l) * (1.0 - l) / (x - l)) } else { c.map(|v| v.min(1.0)) };
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
|
||||
let d = l - lum(c);
|
||||
clip_color(c.map(|v| v + d))
|
||||
}
|
||||
|
||||
/// `c` with saturation `s`: its largest component becomes `s`, its smallest 0 and the middle one
|
||||
/// keeps its proportion.
|
||||
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
if x <= n {
|
||||
return [0.0; 3];
|
||||
}
|
||||
c.map(|v| (v - n) * s / (x - n))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separable_modes_follow_the_reference_formulas() {
|
||||
let (b, s) = ([0.2, 0.5, 0.9], [0.6, 0.25, 0.5]);
|
||||
let cases: [(BlendMode, [f32; 3]); 12] = [
|
||||
(BlendMode::Normal, s),
|
||||
(BlendMode::Multiply, [0.12, 0.125, 0.45]),
|
||||
(BlendMode::Screen, [0.68, 0.625, 0.95]),
|
||||
(BlendMode::Darken, [0.2, 0.25, 0.5]),
|
||||
(BlendMode::Lighten, [0.6, 0.5, 0.9]),
|
||||
(BlendMode::Difference, [0.4, 0.25, 0.4]),
|
||||
(BlendMode::Exclusion, [0.56, 0.5, 0.5]),
|
||||
// Overlay = Hard Light with the layers swapped: b ≤ ½ multiplies, else screens.
|
||||
(BlendMode::Overlay, [0.24, 0.25, 0.9]),
|
||||
(BlendMode::HardLight, [0.36, 0.25, 0.9]),
|
||||
(BlendMode::ColorDodge, [0.5, 0.6666667, 1.0]),
|
||||
(BlendMode::ColorBurn, [0.0, 0.0, 0.8]),
|
||||
// s ≤ ½ darkens by b(1−b)(1−2s); s > ½ lightens towards D(b).
|
||||
(BlendMode::SoftLight, [0.2 + 0.2 * (((16.0 * 0.2 - 12.0) * 0.2 + 4.0) * 0.2 - 0.2), 0.375, 0.9]),
|
||||
];
|
||||
for (m, want) in cases {
|
||||
assert!(near(blend_rgb(m, b, s), want), "{m:?}: {:?} ≠ {want:?}", blend_rgb(m, b, s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_separable_modes_keep_the_reference_luminance() {
|
||||
let (b, s) = ([0.8, 0.3, 0.2], [0.1, 0.4, 0.9]);
|
||||
// Color and Hue take the backdrop's luminance; Luminosity the source's.
|
||||
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] {
|
||||
assert!((lum(blend_rgb(m, b, s)) - lum(b)).abs() < 1e-5, "{m:?}");
|
||||
}
|
||||
assert!((lum(blend_rgb(BlendMode::Luminosity, b, s)) - lum(s)).abs() < 1e-5);
|
||||
// Greys stay finite where rounding puts their luminance just off their components.
|
||||
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] {
|
||||
for (b, s) in [([0.7; 3], [0.0; 3]), ([0.0; 3], [0.3; 3]), ([1.0; 3], [0.9; 3]), ([0.9; 3], [1.0; 3])] {
|
||||
assert!(blend_rgb(m, b, s).iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{m:?} {b:?} {s:?}");
|
||||
}
|
||||
}
|
||||
// Saturation of grey is 0: a grey source desaturates the backdrop to its luminance.
|
||||
let l = lum(b);
|
||||
assert!(near(blend_rgb(BlendMode::Saturation, b, [0.5; 3]), [l; 3]));
|
||||
// Hue of the source with the backdrop's saturation: blue-ish stays blue-ish.
|
||||
let h = blend_rgb(BlendMode::Hue, b, s);
|
||||
assert!(h[2] > h[1] && h[1] > h[0], "{h:?}");
|
||||
assert!((sat(h) - sat(b)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_lays_the_blend_over_the_backdrop_by_alpha() {
|
||||
let (b, s) = ([0.8, 0.4, 0.2, 1.0], [0.5, 0.5, 0.5, 0.5]);
|
||||
let o = composite(BlendMode::Multiply, b, s);
|
||||
// Half of the multiplied colour over half of the backdrop.
|
||||
assert!(near([o[0], o[1], o[2]], [0.6, 0.3, 0.15]) && o[3] == 1.0, "{o:?}");
|
||||
// Over a transparent backdrop the source shows unblended.
|
||||
let o = composite(BlendMode::Difference, [0.3, 0.3, 0.3, 0.0], s);
|
||||
assert!(near([o[0], o[1], o[2]], [0.5; 3]) && (o[3] - 0.5).abs() < 1e-6, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_planes_blend_inks() {
|
||||
let blend = |m: BlendMode, b: [f32; 4], s: [f32; 4]| {
|
||||
let (pb, ps) = (cmyk_planes(b), cmyk_planes(s));
|
||||
planes_cmyk(blend_rgb(m, pb[0], ps[0]), blend_rgb(m, pb[1], ps[1])[0])
|
||||
};
|
||||
let (cyan, magenta) = ([1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]);
|
||||
// Multiply adds the inks: cyan over magenta prints both.
|
||||
assert_eq!(blend(BlendMode::Multiply, magenta, cyan), [1.0, 1.0, 0.0, 0.0]);
|
||||
// Screen keeps the lighter (less ink) per plate.
|
||||
assert_eq!(blend(BlendMode::Screen, magenta, cyan), [0.0; 4]);
|
||||
let (b, s) = ([0.2, 0.6, 0.1, 0.3], [0.7, 0.1, 0.5, 0.8]);
|
||||
// K: backdrop's for Hue, Saturation and Color, the source's for Luminosity.
|
||||
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] {
|
||||
assert!((blend(m, b, s)[3] - b[3]).abs() < 1e-6, "{m:?}");
|
||||
}
|
||||
assert!((blend(BlendMode::Luminosity, b, s)[3] - s[3]).abs() < 1e-6);
|
||||
let back = planes_cmyk(cmyk_planes(b)[0], cmyk_planes(b)[1][0]);
|
||||
assert!(back.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6), "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_labels() {
|
||||
assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn));
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
|
||||
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
|
||||
use moxcms::{
|
||||
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
|
||||
RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d,
|
||||
};
|
||||
|
||||
use super::{CmsError, Intent, ProfileKind};
|
||||
use super::{CmsError, Intent, ProfileKind, lab};
|
||||
|
||||
fn moxcms_intent(i: Intent) -> RenderingIntent {
|
||||
match i {
|
||||
@@ -27,6 +31,8 @@ type Xf = Option<Arc<TransformF32Executor>>;
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
/// The file it was loaded from (empty for profiles made in code).
|
||||
source: Vec<u8>,
|
||||
profile: ColorProfile,
|
||||
srgb: ColorProfile,
|
||||
to_srgb: [OnceLock<Xf>; 4],
|
||||
@@ -58,12 +64,25 @@ impl IccProfile {
|
||||
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
|
||||
};
|
||||
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
|
||||
Ok(Self {
|
||||
name,
|
||||
kind,
|
||||
source: Vec::new(),
|
||||
profile,
|
||||
srgb: ColorProfile::new_srgb(),
|
||||
to_srgb: Default::default(),
|
||||
from_srgb: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
|
||||
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
Self::from_profile(name, p)
|
||||
Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? })
|
||||
}
|
||||
|
||||
/// The profile as an ICC file: the bytes it was loaded from, else encoded.
|
||||
pub fn bytes(&self) -> Result<Vec<u8>, CmsError> {
|
||||
if self.source.is_empty() { encode(&self.profile) } else { Ok(self.source.clone()) }
|
||||
}
|
||||
|
||||
fn layout(&self) -> Layout {
|
||||
@@ -122,14 +141,104 @@ impl IccProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
let p = match name {
|
||||
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
|
||||
/// The primaries and tone curves of built-in RGB space `name`.
|
||||
fn rgb_space(name: &str) -> Option<ColorProfile> {
|
||||
Some(match name {
|
||||
super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(),
|
||||
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
|
||||
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
|
||||
super::SRGB => ColorProfile::new_srgb(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
IccProfile::from_profile(Some(name.to_string()), rgb_space(name)?).ok()
|
||||
}
|
||||
|
||||
/// Built-in RGB space `name` as an ICC file, under its name here.
|
||||
pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
|
||||
encode(&labelled(rgb_space(name).ok_or_else(|| CmsError::UnknownProfile(name.into()))?, name))
|
||||
}
|
||||
|
||||
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
|
||||
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
|
||||
if let Some(date) = bytes.get_mut(24..36) {
|
||||
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn text_tag(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
/// Profiles made here carry their name and no copyright claim.
|
||||
fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
p.description = text_tag(name);
|
||||
p.copyright = text_tag("No copyright, use freely");
|
||||
p
|
||||
}
|
||||
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
|
||||
for i in 0..A2B_GRID.pow(4) {
|
||||
let cmyk = [3, 2, 1, 0].map(|d| unit(i / A2B_GRID.pow(d) % A2B_GRID, A2B_GRID));
|
||||
let l = to_lab(cmyk);
|
||||
a2b.extend([l.l / 100.0, (l.a + 128.0) / 255.0, (l.b + 128.0) / 255.0].map(q));
|
||||
}
|
||||
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
|
||||
for i in 0..B2A_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
grid_points[..inputs].fill(grid as u8);
|
||||
LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: inputs as u8,
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
bias: Vector3d::default(),
|
||||
})
|
||||
};
|
||||
IccProfile::from_profile(Some(name.to_string()), p).ok()
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
p.pcs = DataColorSpace::Lab;
|
||||
p.rendering_intent = RenderingIntent::RelativeColorimetric;
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
/// The grey space of greyscale exports: sRGB's tone curve, D50 white.
|
||||
pub(super) fn gray_profile(name: &str) -> ColorProfile {
|
||||
let mut p = ColorProfile::new_gray_with_gamma(2.2);
|
||||
p.gray_trc = ColorProfile::new_srgb().red_trc;
|
||||
labelled(p, name)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
|
||||
//! gamut checks and soft-proof transforms.
|
||||
//!
|
||||
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
|
||||
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
|
||||
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
|
||||
//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
|
||||
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
|
||||
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
|
||||
@@ -25,13 +25,24 @@ pub use lab::{Lab, delta_e76, delta_e2000};
|
||||
use crate::Color;
|
||||
|
||||
pub const SRGB: &str = "sRGB IEC61966-2.1";
|
||||
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
|
||||
/// The wide-gamut RGB space with the 1998 primaries (γ 2.2, D65).
|
||||
pub const WIDE_GAMUT_RGB: &str = "Wide Gamut RGB (1998 primaries)";
|
||||
pub const DISPLAY_P3: &str = "Display P3";
|
||||
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
|
||||
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
|
||||
pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)";
|
||||
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
|
||||
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
|
||||
/// The grey space greyscale exports are written in: sRGB's tone curve (a grey shows as on screen).
|
||||
pub const GRAY: &str = "Gray (sRGB tone curve)";
|
||||
|
||||
/// Built-in RGB and CMYK working spaces, in menu order.
|
||||
const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB];
|
||||
const BUILTIN_CMYK: [&str; 2] = [GENERIC_CMYK, DEVICE_CMYK];
|
||||
|
||||
/// Names earlier versions gave built-in profiles: (old name, current name). Settings, documents
|
||||
/// and commands that still use an old name get the current profile.
|
||||
pub const LEGACY_NAMES: &[(&str, &str)] = &[("Adobe RGB (1998) compatible", WIDE_GAMUT_RGB)]; // brand-ok: legacy alias
|
||||
|
||||
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
|
||||
pub const GAMUT_THRESHOLD: f32 = 2.0;
|
||||
@@ -131,6 +142,8 @@ pub enum Model {
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Gray,
|
||||
/// CIE L*a*b* (D50).
|
||||
Lab,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -169,7 +182,7 @@ fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
|
||||
}
|
||||
|
||||
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
|
||||
match name {
|
||||
match canonical_name(name) {
|
||||
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
|
||||
DEVICE_CMYK => Ok(CmykSpace::Device),
|
||||
_ => match user_profile(name) {
|
||||
@@ -194,12 +207,23 @@ fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current name of a profile: names earlier versions used resolve to today's name.
|
||||
pub fn canonical_name(name: &str) -> &str {
|
||||
LEGACY_NAMES.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new)
|
||||
}
|
||||
|
||||
/// An available profile by its current or legacy name.
|
||||
pub fn profile(name: &str) -> Option<ProfileInfo> {
|
||||
let name = canonical_name(name);
|
||||
profiles().into_iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Every profile available (built-in first, then user-loaded).
|
||||
pub fn profiles() -> Vec<ProfileInfo> {
|
||||
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
|
||||
let mut v: Vec<ProfileInfo> = BUILTIN_RGB
|
||||
.iter()
|
||||
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
|
||||
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.chain(BUILTIN_CMYK.iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.collect();
|
||||
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
|
||||
@@ -214,7 +238,8 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
if p.kind == ProfileKind::Gray {
|
||||
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
|
||||
}
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
let builtin = canonical_name(&p.name);
|
||||
if BUILTIN_RGB.contains(&builtin) || BUILTIN_CMYK.contains(&builtin) {
|
||||
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
|
||||
}
|
||||
if !p.usable() {
|
||||
@@ -229,6 +254,33 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// model (both directions, perceptual and relative colorimetric, Lab PCS); a profile the user
|
||||
/// loaded is the file it came from. Cached.
|
||||
pub fn icc_bytes(name: &str) -> Result<Arc<[u8]>, CmsError> {
|
||||
static CACHE: Mutex<Vec<(String, Arc<[u8]>)>> = Mutex::new(Vec::new());
|
||||
let name = canonical_name(name);
|
||||
if let Some(p) = user_profile(name) {
|
||||
return p.bytes().map(Arc::from);
|
||||
}
|
||||
if let Some((_, b)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let bytes: Arc<[u8]> = if name == GRAY {
|
||||
icc::encode(&icc::gray_profile(name))?
|
||||
} else if BUILTIN_CMYK.contains(&name) {
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..ColorSettings::default() })?;
|
||||
icc::encode(&icc::cmyk_profile(name, |c| cms.cmyk_to_lab(c), |l| cms.lab_to_cmyk(l, Intent::RelativeColorimetric)))?
|
||||
} else {
|
||||
icc::builtin_rgb_bytes(name)?
|
||||
}
|
||||
.into();
|
||||
CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), bytes.clone()));
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Load a `.icc`/`.icm` file from disk (native only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
|
||||
@@ -257,8 +309,10 @@ impl Default for Cms {
|
||||
}
|
||||
|
||||
impl Cms {
|
||||
/// Profiles named by a legacy name are stored under their current name.
|
||||
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
|
||||
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
|
||||
let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() };
|
||||
Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings })
|
||||
}
|
||||
pub fn settings(&self) -> &ColorSettings {
|
||||
&self.settings
|
||||
@@ -331,6 +385,7 @@ impl Cms {
|
||||
Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false),
|
||||
Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3],
|
||||
Color::Lab { l, a, b } => lab::lab_to_srgb(Lab::new(l, a, b)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,23 +393,33 @@ impl Cms {
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
Color::Lab { l, a, b } => Lab::new(l, a, b),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only).
|
||||
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only;
|
||||
/// Lab separates from its own values).
|
||||
pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [0.0, 0.0, 0.0, k],
|
||||
Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent),
|
||||
Color::Lab { l, a, b } => self.lab_to_cmyk(Lab::new(l, a, b), intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `c` into `model` (a colour already in `model` is returned unchanged).
|
||||
pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c,
|
||||
(Color::Rgb { .. }, Model::Rgb)
|
||||
| (Color::Cmyk { .. }, Model::Cmyk)
|
||||
| (Color::Gray { .. }, Model::Gray)
|
||||
| (Color::Lab { .. }, Model::Lab) => *c,
|
||||
(_, Model::Lab) => {
|
||||
let Lab { l, a, b } = self.lab(c);
|
||||
Color::Lab { l, a, b }
|
||||
}
|
||||
(_, Model::Cmyk) => {
|
||||
let [c, m, y, k] = self.to_cmyk(c, intent);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
@@ -381,19 +446,18 @@ impl Cms {
|
||||
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
|
||||
pub fn gamut_error(&self, c: &Color) -> f32 {
|
||||
match c {
|
||||
Color::Rgb { .. } => {
|
||||
let srgb = self.display_rgb(c);
|
||||
let src = lab::srgb_to_lab(srgb);
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let src = self.lab(c);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
|
||||
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
|
||||
delta_e2000(src, lab::srgb_to_lab(back))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
Color::Cmyk { .. } | Color::Gray { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,3 +700,5 @@ pub(crate) fn cmyk_is_device() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_icc;
|
||||
@@ -138,11 +138,11 @@ fn convert_between_models() {
|
||||
|
||||
#[test]
|
||||
fn wide_gamut_working_space() {
|
||||
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
|
||||
let c = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert!(!c.rgb_is_srgb());
|
||||
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
|
||||
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
|
||||
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
|
||||
// Wide-gamut green is outside sRGB: it clips to (nearly) full sRGB green.
|
||||
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
|
||||
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
|
||||
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
|
||||
@@ -150,6 +150,21 @@ fn wide_gamut_working_space() {
|
||||
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_profile_name_resolves() {
|
||||
let (old, _) = LEGACY_NAMES[0];
|
||||
assert_eq!(canonical_name(old), WIDE_GAMUT_RGB);
|
||||
assert_eq!(canonical_name(SRGB), SRGB);
|
||||
assert_eq!(profile(old).map(|p| (p.name, p.kind, p.builtin)), Some((WIDE_GAMUT_RGB.to_string(), ProfileKind::Rgb, true)));
|
||||
assert!(profile("Nope").is_none());
|
||||
assert!(profiles().iter().all(|p| p.name != old), "only the current name is listed");
|
||||
// Settings saved with the old name load as the current profile, under its current name.
|
||||
let c = Cms::new(&ColorSettings { rgb: old.into(), ..Default::default() }).unwrap();
|
||||
assert_eq!(c.settings().rgb, WIDE_GAMUT_RGB);
|
||||
let same = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert_eq!(c.rgb_to_srgb([0.0, 1.0, 0.0]), same.rgb_to_srgb([0.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_lut_matches_direct_proof() {
|
||||
let c = cms();
|
||||
@@ -200,3 +215,29 @@ fn loads_system_cmyk_icc() {
|
||||
assert!(de(rgb, back) < 5.0, "{back:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
let ri = Intent::RelativeColorimetric;
|
||||
// In-gamut Lab colours separate into working CMYK and come back within a small ΔE.
|
||||
for (l, a, b) in [(60.0, 20.0, 30.0), (50.0, -20.0, -10.0), (75.0, 5.0, 40.0), (40.0, 30.0, -25.0)] {
|
||||
let lab = Color::lab(l, a, b);
|
||||
assert_eq!(c.lab(&lab), Lab::new(l, a, b), "Lab passes through");
|
||||
assert_eq!(c.convert(&lab, Model::Lab, ri), lab, "already Lab: unchanged");
|
||||
let ink = c.convert(&lab, Model::Cmyk, ri);
|
||||
let Color::Cmyk { c: cc, m, y, k } = ink else { panic!("CMYK") };
|
||||
assert_eq!([cc, m, y, k], c.to_cmyk(&lab, ri), "separation = conversion");
|
||||
let back = c.lab(&ink);
|
||||
assert!(delta_e2000(back, Lab::new(l, a, b)) < 1.5, "{l} {a} {b} → {ink:?} → {back:?}");
|
||||
assert!(!c.out_of_gamut(&lab), "{l} {a} {b} is printable");
|
||||
// Through RGB and back to Lab.
|
||||
let rgb = c.convert(&lab, Model::Rgb, ri);
|
||||
let Color::Lab { l: l2, a: a2, b: b2 } = c.convert(&rgb, Model::Lab, ri) else { panic!("Lab") };
|
||||
assert!(delta_e76(Lab::new(l2, a2, b2), Lab::new(l, a, b)) < 0.05, "{l} {a} {b} → {rgb:?} → {l2} {a2} {b2}");
|
||||
}
|
||||
// A saturated Lab blue no press reaches is out of gamut; Lab grey converts to a neutral.
|
||||
assert!(c.out_of_gamut(&Color::lab(30.0, 60.0, -100.0)));
|
||||
let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") };
|
||||
assert!((k - 0.53).abs() < 0.01, "{k}");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
fn parse(bytes: &[u8]) -> moxcms::ColorProfile {
|
||||
moxcms::ColorProfile::new_from_slice(bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_rgb_spaces_encode_under_their_names() {
|
||||
for name in BUILTIN_RGB {
|
||||
let p = IccProfile::from_bytes(None, &icc_bytes(name).unwrap()).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Rgb));
|
||||
}
|
||||
// Legacy names find today's profile.
|
||||
assert_eq!(icc_bytes(LEGACY_NAMES[0].0).unwrap(), icc_bytes(WIDE_GAMUT_RGB).unwrap());
|
||||
assert!(matches!(icc_bytes("No Such Profile"), Err(CmsError::UnknownProfile(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_profiles_convert_like_the_cms() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let bytes = icc_bytes(name).unwrap();
|
||||
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();
|
||||
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();
|
||||
let de = delta_e2000(lab::srgb_to_lab(want), lab::srgb_to_lab(got));
|
||||
assert!(de < 2.5, "{name} {cmyk:?}: {want:?} vs {got:?} (ΔE {de})");
|
||||
}
|
||||
// And back: an in-gamut colour separates to about the same inks.
|
||||
let srgb = cms.cmyk_to_srgb([0.1, 0.6, 0.8, 0.0], false);
|
||||
let back = p.from_srgb(srgb, Intent::RelativeColorimetric).unwrap();
|
||||
let again = cms.cmyk_to_srgb([back[0], back[1], back[2], back[3]], false);
|
||||
assert!(delta_e2000(lab::srgb_to_lab(srgb), lab::srgb_to_lab(again)) < 3.0, "{back:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_is_srgb_toned() {
|
||||
let p = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.kind), (GRAY, ProfileKind::Gray));
|
||||
for v in [0.0f32, 0.25, 0.5, 1.0] {
|
||||
let got = p.to_srgb(&[v], Intent::RelativeColorimetric).unwrap();
|
||||
assert!(got.iter().all(|c| (c - v).abs() < 0.01), "{v} → {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_profiles_are_reproducible() {
|
||||
assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_profile_is_embedded_as_loaded() {
|
||||
let mut bytes = icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap();
|
||||
// A byte the parser ignores (in the reserved header area) marks the file as this one.
|
||||
bytes[100] = 7;
|
||||
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
|
||||
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Freeform gradients: colour points placed anywhere on an object (and smooth lines through them),
|
||||
//! blended across it.
|
||||
//!
|
||||
//! Each point paints a disc of pure colour (its spread, a fraction of half the larger side of the
|
||||
//! painted box) and blends into its neighbours beyond it by inverse-distance weighting on the
|
||||
//! distance from the disc's edge. A line is a Catmull-Rom curve through its points: every spot
|
||||
//! on it carries the colour, opacity and spread interpolated between the two points it lies
|
||||
//! between, and acts as a source along its length (each short piece of it weighted by its share
|
||||
//! of the line). Where discs overlap the deeper one wins, so the field stays continuous.
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Color, Gradient, GradientStop};
|
||||
|
||||
/// The spread a new point gets.
|
||||
pub const DEFAULT_SPREAD: f32 = 0.0;
|
||||
/// Line segments are tessellated into this many pieces for sampling.
|
||||
const LINE_STEPS: usize = 16;
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn default_spread() -> f32 {
|
||||
DEFAULT_SPREAD
|
||||
}
|
||||
|
||||
/// One colour point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FreeformPoint {
|
||||
/// Position in the paint's space (document coordinates; text space for type runs).
|
||||
pub at: Point,
|
||||
pub color: Color,
|
||||
#[serde(default = "one")]
|
||||
pub opacity: f32,
|
||||
/// Radius (0..=1) of the disc of pure colour around the point, as a fraction of
|
||||
/// [`spread_scale`].
|
||||
#[serde(default = "default_spread")]
|
||||
pub spread: f32,
|
||||
}
|
||||
|
||||
impl FreeformPoint {
|
||||
pub fn new(at: Point, color: Color) -> Self {
|
||||
Self { at, color, opacity: 1.0, spread: DEFAULT_SPREAD }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the Gradient tool adds points: free points, or points joined into lines.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FreeformMode {
|
||||
#[default]
|
||||
Points,
|
||||
Lines,
|
||||
}
|
||||
|
||||
impl FreeformMode {
|
||||
/// Parse a mode name (`points`, `lines`; any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[FreeformMode::Points, FreeformMode::Lines].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FreeformMode::Points => "Points",
|
||||
FreeformMode::Lines => "Lines",
|
||||
}
|
||||
}
|
||||
fn is_points(&self) -> bool {
|
||||
*self == FreeformMode::Points
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient: its colour points and the lines through them.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Freeform {
|
||||
pub points: Vec<FreeformPoint>,
|
||||
/// Each line is a smooth curve through `points` at these indices, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub lines: Vec<Vec<usize>>,
|
||||
/// Draw: how the Gradient tool adds points.
|
||||
#[serde(default, skip_serializing_if = "FreeformMode::is_points")]
|
||||
pub mode: FreeformMode,
|
||||
}
|
||||
|
||||
/// The length spreads are fractions of: half the larger side of the painted box.
|
||||
pub fn spread_scale(b: Rect) -> f64 {
|
||||
b.width().abs().max(b.height().abs()) / 2.0
|
||||
}
|
||||
|
||||
/// The box a freeform gradient painted over `bounds` is placed on: `bounds` with positive sides, a
|
||||
/// side without length getting a sliver (so a grid over it maps back invertibly). `None` for an
|
||||
/// empty or non-finite box.
|
||||
pub fn painted_box(bounds: Rect) -> Option<Rect> {
|
||||
let b = bounds.abs();
|
||||
let side = b.width().max(b.height());
|
||||
(side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))))
|
||||
}
|
||||
|
||||
/// Device pixels per cell of the grid a freeform gradient is sampled on.
|
||||
const CELL_PX: f64 = 4.0;
|
||||
/// Fewest and most cells along the painted box's longer side.
|
||||
const MIN_CELLS: f64 = 8.0;
|
||||
const MAX_CELLS: f64 = 256.0;
|
||||
|
||||
/// The grid (columns, rows) a freeform gradient on box `b` (see [`painted_box`]) is sampled on
|
||||
/// when the box's longer side spans `device` pixels: a cell per few pixels, in power-of-two steps
|
||||
/// (so zooming reuses grids), 8 to 256 cells along the longer side.
|
||||
pub fn grid_size(b: Rect, device: f64) -> (u16, u16) {
|
||||
let side = b.width().max(b.height());
|
||||
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
|
||||
let along = |len: f64| (cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16;
|
||||
(along(b.width()), along(b.height()))
|
||||
}
|
||||
|
||||
/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LineHit {
|
||||
pub line: usize,
|
||||
/// The segment from the line's point `segment` to point `segment + 1`.
|
||||
pub segment: usize,
|
||||
/// Curve parameter along the segment, 0..=1.
|
||||
pub t: f64,
|
||||
pub point: Point,
|
||||
pub distance: f64,
|
||||
}
|
||||
|
||||
impl Freeform {
|
||||
/// The points the first application places on an object with box `b`: a 2 × n grid (n = 2–4
|
||||
/// along the longer side), each moved to the nearest spot `inside` the shape when its grid
|
||||
/// position isn't. They take colours along `g`, in a loop around the grid.
|
||||
pub fn auto(b: Rect, g: &Gradient, inside: &dyn Fn(Point) -> bool) -> Self {
|
||||
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
|
||||
let long = ((2.0 * w.max(h) / w.min(h)).round() as usize).clamp(2, 4);
|
||||
let (cols, rows) = if w >= h { (long, 2) } else { (2, long) };
|
||||
let cell = |i: usize, n: usize| (i as f64 + 0.5) / n as f64;
|
||||
// Row by row, every other row backwards: neighbours in the list are neighbours on the art.
|
||||
let targets: Vec<Point> = (0..rows)
|
||||
.flat_map(|j| (0..cols).map(move |k| if j % 2 == 0 { (k, j) } else { (cols - 1 - k, j) }))
|
||||
.map(|(i, j)| Point::new(b.x0 + cell(i, cols) * w, b.y0 + cell(j, rows) * h))
|
||||
.collect();
|
||||
const GRID: usize = 24;
|
||||
let candidates: Vec<Point> =
|
||||
(0..GRID * GRID).map(|k| Point::new(b.x0 + cell(k % GRID, GRID) * w, b.y0 + cell(k / GRID, GRID) * h)).filter(|p| inside(*p)).collect();
|
||||
let apart = w.min(h) * 0.15;
|
||||
let mut placed: Vec<Point> = Vec::with_capacity(targets.len());
|
||||
for t in &targets {
|
||||
let free = |c: &&Point| placed.iter().all(|q| q.distance(**c) >= apart);
|
||||
let at = if inside(*t) {
|
||||
*t
|
||||
} else {
|
||||
candidates.iter().filter(free).min_by(|a, b| a.distance_squared(*t).total_cmp(&b.distance_squared(*t))).copied().unwrap_or(*t)
|
||||
};
|
||||
placed.push(at);
|
||||
}
|
||||
let mut f = Self { points: placed.into_iter().map(|at| FreeformPoint::new(at, Color::BLACK)).collect(), ..Default::default() };
|
||||
f.recolor(g);
|
||||
f
|
||||
}
|
||||
|
||||
/// Colour the points along `g`, first to last.
|
||||
pub fn recolor(&mut self, g: &Gradient) {
|
||||
let n = self.points.len();
|
||||
for (k, p) in self.points.iter_mut().enumerate() {
|
||||
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient stops standing for the points (their colours evenly spaced, at least two): what
|
||||
/// swatch chips, exports without freeform shading and re-seeding on other art use.
|
||||
pub fn stops(&self) -> Vec<GradientStop> {
|
||||
let n = self.points.len();
|
||||
let stop = |offset: f32, p: &FreeformPoint| GradientStop { opacity: p.opacity, ..GradientStop::new(offset, p.color) };
|
||||
match n {
|
||||
0 => Gradient::default().stops,
|
||||
1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])],
|
||||
_ => self.points.iter().enumerate().map(|(i, p)| stop(i as f32 / (n - 1) as f32, p)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map every point through `f`.
|
||||
pub fn map_points(&mut self, f: impl Fn(Point) -> Point) {
|
||||
for p in &mut self.points {
|
||||
p.at = f(p.at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map every point through `a`.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.map_points(|p| a * p);
|
||||
}
|
||||
|
||||
/// The cubic segments of line `line` (a Catmull-Rom curve through its points).
|
||||
pub fn segments(&self, line: usize) -> Vec<CubicBez> {
|
||||
let Some(ix) = self.lines.get(line) else { return vec![] };
|
||||
// A line naming a missing point (a damaged file) draws nothing.
|
||||
let Some(pts) = ix.iter().map(|i| self.points.get(*i).map(|p| p.at)).collect::<Option<Vec<Point>>>() else { return vec![] };
|
||||
let n = pts.len();
|
||||
(0..n.saturating_sub(1))
|
||||
.map(|i| {
|
||||
let (p0, p1, p2, p3) = (pts[i.saturating_sub(1)], pts[i], pts[i + 1], pts[(i + 2).min(n - 1)]);
|
||||
CubicBez::new(p1, p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Line `line` as a path (for drawing it).
|
||||
pub fn line_path(&self, line: usize) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for (i, c) in self.segments(line).into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
bp.move_to(c.p0);
|
||||
}
|
||||
bp.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
/// The spot on a line nearest `p`.
|
||||
pub fn nearest_on_lines(&self, p: Point) -> Option<LineHit> {
|
||||
let mut best: Option<LineHit> = None;
|
||||
for line in 0..self.lines.len() {
|
||||
for (segment, c) in self.segments(line).into_iter().enumerate() {
|
||||
let n = c.nearest(p, 1e-6);
|
||||
let distance = n.distance_sq.sqrt();
|
||||
if best.is_none_or(|b| distance < b.distance) {
|
||||
best = Some(LineHit { line, segment, t: n.t, point: c.eval(n.t), distance });
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
|
||||
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
|
||||
let ([r, g, b], a) = self.field(scale).sample(p);
|
||||
(Color::rgb(r, g, b), a)
|
||||
}
|
||||
|
||||
/// The colour field, with colours converted and lines tessellated once (for sampling many
|
||||
/// spots).
|
||||
pub fn field(&self, scale: f64) -> Field {
|
||||
self.field_with(scale, &Color::to_rgb)
|
||||
}
|
||||
|
||||
/// [`Self::field`] with colours turned into the three blended values by `rgb` (CMYK
|
||||
/// documents blend ink planes, see [`crate::blend::cmyk_planes`]).
|
||||
pub fn field_with(&self, scale: f64, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Field {
|
||||
let src = |p: &FreeformPoint| Source { at: p.at, rgb: rgb(&p.color), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale };
|
||||
let points: Vec<Source> = self.points.iter().map(src).collect();
|
||||
let mut pieces = vec![];
|
||||
for (l, ix) in self.lines.iter().enumerate() {
|
||||
// The line tessellated into a polyline of sources.
|
||||
let mut v: Vec<Source> = vec![];
|
||||
for (s, c) in self.segments(l).into_iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (ix.get(s).and_then(|i| points.get(*i)), ix.get(s + 1).and_then(|i| points.get(*i))) else { continue };
|
||||
for k in (if s == 0 { 0 } else { 1 })..=LINE_STEPS {
|
||||
let t = k as f64 / LINE_STEPS as f64;
|
||||
v.push(Source { at: c.eval(t), ..a.lerp(b, t as f32) });
|
||||
}
|
||||
}
|
||||
let len: f64 = v.windows(2).map(|w| w[0].at.distance(w[1].at)).sum();
|
||||
let n = v.len().saturating_sub(1).max(1) as f64;
|
||||
pieces.extend(v.windows(2).map(|w| Piece { a: w[0], b: w[1], share: if len > 0.0 { w[0].at.distance(w[1].at) / len } else { 1.0 / n } }));
|
||||
}
|
||||
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
|
||||
}
|
||||
|
||||
/// Add a point; returns its index.
|
||||
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
|
||||
self.points.push(p);
|
||||
self.points.len() - 1
|
||||
}
|
||||
|
||||
/// Join point `to` to point `from`: extend the line that ends (or starts) at `from`, else start
|
||||
/// a line from it. Returns the line's index (None when an index is out of range or equal).
|
||||
pub fn connect(&mut self, from: usize, to: usize) -> Option<usize> {
|
||||
if from == to || from >= self.points.len() || to >= self.points.len() {
|
||||
return None;
|
||||
}
|
||||
if let Some(l) = self.lines.iter().position(|l| l.last() == Some(&from)) {
|
||||
self.lines[l].push(to);
|
||||
return Some(l);
|
||||
}
|
||||
if let Some(l) = self.lines.iter().position(|l| l.first() == Some(&from)) {
|
||||
self.lines[l].insert(0, to);
|
||||
return Some(l);
|
||||
}
|
||||
self.lines.push(vec![from, to]);
|
||||
Some(self.lines.len() - 1)
|
||||
}
|
||||
|
||||
/// Add a line through `points` (at least two, each a valid index, none twice in a row).
|
||||
pub fn add_line(&mut self, points: Vec<usize>) -> Result<usize, String> {
|
||||
if points.len() < 2 {
|
||||
return Err("a line needs at least two points".into());
|
||||
}
|
||||
if let Some(i) = points.iter().find(|i| **i >= self.points.len()) {
|
||||
return Err(format!("no point {i} (the gradient has {})", self.points.len()));
|
||||
}
|
||||
if points.windows(2).any(|w| w[0] == w[1]) {
|
||||
return Err("a line can't run from a point to itself".into());
|
||||
}
|
||||
self.lines.push(points);
|
||||
Ok(self.lines.len() - 1)
|
||||
}
|
||||
|
||||
/// Remove point `i` (never the last one). Lines through it close up around it; lines left
|
||||
/// with fewer than two points go.
|
||||
pub fn remove_point(&mut self, i: usize) -> bool {
|
||||
if i >= self.points.len() || self.points.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
self.points.remove(i);
|
||||
for l in &mut self.lines {
|
||||
l.retain(|j| *j != i);
|
||||
for j in l.iter_mut() {
|
||||
if *j > i {
|
||||
*j -= 1;
|
||||
}
|
||||
}
|
||||
l.dedup();
|
||||
}
|
||||
self.lines.retain(|l| l.len() >= 2);
|
||||
true
|
||||
}
|
||||
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
|
||||
/// opacity and spread between the segment's ends. Returns the new point's index.
|
||||
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
|
||||
let c = *self.segments(line).get(segment)?;
|
||||
let l = &self.lines[line];
|
||||
let (a, b) = (self.points[l[segment]], self.points[l[segment + 1]]);
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let u = t as f32;
|
||||
let p = FreeformPoint {
|
||||
at: c.eval(t),
|
||||
color: a.color.lerp(&b.color, u),
|
||||
opacity: a.opacity + (b.opacity - a.opacity) * u,
|
||||
spread: a.spread + (b.spread - a.spread) * u,
|
||||
};
|
||||
let i = self.add_point(p);
|
||||
self.lines[line].insert(segment + 1, i);
|
||||
Some(i)
|
||||
}
|
||||
}
|
||||
|
||||
/// One colour source of a [`Field`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Source {
|
||||
at: Point,
|
||||
rgb: [f32; 3],
|
||||
opacity: f32,
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn lerp(&self, o: &Source, t: f32) -> Source {
|
||||
let l = |a: f32, b: f32| a + (b - a) * t;
|
||||
Source {
|
||||
at: self.at.lerp(o.at, t as f64),
|
||||
rgb: [l(self.rgb[0], o.rgb[0]), l(self.rgb[1], o.rgb[1]), l(self.rgb[2], o.rgb[2])],
|
||||
opacity: l(self.opacity, o.opacity),
|
||||
radius: self.radius + (o.radius - self.radius) * t as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient ready to sample ([`Freeform::field`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Field {
|
||||
points: Vec<Source>,
|
||||
/// The lines tessellated into short pieces.
|
||||
pieces: Vec<Piece>,
|
||||
eps: f64,
|
||||
}
|
||||
|
||||
/// A piece of a tessellated line: a source at its spot nearest the sample, weighted by `share`
|
||||
/// (its fraction of the line's length), so a whole line weighs as much as a point from afar and
|
||||
/// the field stays continuous where the nearest spot on the line jumps.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Piece {
|
||||
a: Source,
|
||||
b: Source,
|
||||
share: f64,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
/// The distance from `p` and the source at the nearest spot.
|
||||
fn nearest(&self, p: Point) -> (f64, Source) {
|
||||
let v = self.b.at - self.a.at;
|
||||
let l2 = v.hypot2();
|
||||
let t = if l2 > 0.0 { ((p - self.a.at).dot(v) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let s = self.a.lerp(&self.b, t as f32);
|
||||
(s.at.distance(p), s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and
|
||||
/// opacity (as [`Self::sample`]).
|
||||
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
|
||||
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
|
||||
(0..rows as usize * cols as usize).map(move |k| {
|
||||
let (i, j) = (k % cols as usize, k / cols as usize);
|
||||
self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch))
|
||||
})
|
||||
}
|
||||
|
||||
/// Display RGB and opacity at `p`.
|
||||
pub fn sample(&self, p: Point) -> ([f32; 3], f32) {
|
||||
// (weight sum, weighted rgba) inside discs, and blending by distance outside them.
|
||||
let mut disc = (0.0f64, [0.0f64; 4]);
|
||||
let mut far = (0.0f64, [0.0f64; 4]);
|
||||
let mut add = |d: f64, s: &Source, share: f64| {
|
||||
let (acc, w) = if d < s.radius || d <= self.eps {
|
||||
// The deeper inside, the stronger: weights fade to nothing at the disc's edge.
|
||||
(&mut disc, (s.radius - d + self.eps) * share)
|
||||
} else {
|
||||
let e = d - s.radius;
|
||||
(&mut far, share / (e * e))
|
||||
};
|
||||
acc.0 += w;
|
||||
for (k, v) in s.rgb.iter().chain(std::iter::once(&s.opacity)).enumerate() {
|
||||
acc.1[k] += w * *v as f64;
|
||||
}
|
||||
};
|
||||
for s in &self.points {
|
||||
add(s.at.distance(p), s, 1.0);
|
||||
}
|
||||
for piece in &self.pieces {
|
||||
let (d, s) = piece.nearest(p);
|
||||
add(d, &s, piece.share);
|
||||
}
|
||||
let (w, acc) = if disc.0 > 0.0 { disc } else { far };
|
||||
if !(w > 0.0 && w.is_finite()) {
|
||||
return ([0.0; 3], 0.0);
|
||||
}
|
||||
let c = |k: usize| (acc[k] / w) as f32;
|
||||
([c(0), c(1), c(2)], c(3))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn red() -> Color {
|
||||
Color::rgb(1.0, 0.0, 0.0)
|
||||
}
|
||||
fn blue() -> Color {
|
||||
Color::rgb(0.0, 0.0, 1.0)
|
||||
}
|
||||
|
||||
fn two(spread: f32) -> Freeform {
|
||||
Freeform {
|
||||
points: vec![
|
||||
FreeformPoint { spread, ..FreeformPoint::new(Point::new(0.0, 0.0), red()) },
|
||||
FreeformPoint { spread, opacity: 0.5, ..FreeformPoint::new(Point::new(100.0, 0.0), blue()) },
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_at_a_point_is_its_colour() {
|
||||
let f = two(0.0);
|
||||
let (c, o) = f.sample(Point::new(0.0, 0.0), 50.0);
|
||||
assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]) && (o - 1.0).abs() < 1e-6, "{c:?} {o}");
|
||||
let (c, o) = f.sample(Point::new(100.0, 0.0), 50.0);
|
||||
assert!(close(c.to_rgb(), [0.0, 0.0, 1.0]) && (o - 0.5).abs() < 1e-6);
|
||||
// Half way between equal points: an even mix.
|
||||
let (c, o) = f.sample(Point::new(50.0, 0.0), 50.0);
|
||||
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
|
||||
// Scale 100: a 0.2 spread is a 20 pt disc.
|
||||
let mut f = two(0.0);
|
||||
f.points[0].spread = 0.2;
|
||||
let at = |x: f64| f.sample(Point::new(x, 0.0), 100.0).0.to_rgb()[0];
|
||||
assert!((at(15.0) - 1.0).abs() < 1e-6, "inside the disc: pure red");
|
||||
assert!(at(30.0) < 1.0 && at(30.0) > at(50.0) && at(50.0) > at(70.0), "falls off: {} {} {}", at(30.0), at(50.0), at(70.0));
|
||||
// Without the spread the same spot is bluer.
|
||||
let plain = two(0.0).sample(Point::new(30.0, 0.0), 100.0).0.to_rgb()[0];
|
||||
assert!(at(30.0) > plain);
|
||||
// Overlapping discs blend continuously (no jump at the edge of the smaller one).
|
||||
f.points[1].spread = 0.9;
|
||||
let a = f.sample(Point::new(10.01, 0.0), 100.0).0.to_rgb()[0];
|
||||
let b = f.sample(Point::new(9.99, 0.0), 100.0).0.to_rgb()[0];
|
||||
assert!((a - b).abs() < 0.01, "{a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_carry_colour_between_their_points() {
|
||||
let mut f = two(0.0);
|
||||
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
|
||||
let off_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
|
||||
f.add_line(vec![0, 1]).unwrap();
|
||||
let on_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
|
||||
// Near the line's middle the red-blue mix dominates the green point.
|
||||
assert!(on_line[1] < off_line[1], "{on_line:?} vs {off_line:?}");
|
||||
let hit = f.nearest_on_lines(Point::new(50.0, 7.0)).unwrap();
|
||||
assert_eq!((hit.line, hit.segment), (0, 0));
|
||||
assert!((hit.t - 0.5).abs() < 1e-3 && (hit.distance - 7.0).abs() < 1e-3, "{hit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_field_is_continuous_around_lines() {
|
||||
// A bent line: where the nearest spot on it jumps the colour must not.
|
||||
let mut f = two(0.0);
|
||||
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
|
||||
f.points.push(FreeformPoint { spread: 0.1, ..FreeformPoint::new(Point::new(-50.0, 80.0), Color::WHITE) });
|
||||
f.add_line(vec![0, 1, 2]).unwrap();
|
||||
let field = f.field(100.0);
|
||||
let at = |x: f64, y: f64| field.sample(Point::new(x, y)).0;
|
||||
for i in 0..80 {
|
||||
for j in 0..80 {
|
||||
let (x, y) = (-60.0 + i as f64 * 2.5, -60.0 + j as f64 * 2.5);
|
||||
let (a, b, c) = (at(x, y), at(x + 0.5, y), at(x, y + 0.5));
|
||||
let step = (0..3).map(|k| (a[k] - b[k]).abs().max((a[k] - c[k]).abs())).fold(0.0, f32::max);
|
||||
assert!(step < 0.06, "jump of {step} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_places_four_or_more_points_inside_the_shape() {
|
||||
let g = Gradient::default();
|
||||
let b = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
// A triangle with its apex at the top middle.
|
||||
let inside = |p: Point| p.y >= 0.0 && p.y <= 100.0 && (p.x - 100.0).abs() <= p.y;
|
||||
let f = Freeform::auto(b, &g, &inside);
|
||||
assert!(f.points.len() >= 4, "{}", f.points.len());
|
||||
assert!(f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
|
||||
// Colours run from the gradient's start to its end.
|
||||
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ffffff".into(), "#000000".into()));
|
||||
// A long box gets more points along its length.
|
||||
assert!(Freeform::auto(Rect::new(0.0, 0.0, 400.0, 100.0), &g, &|_| true).points.len() > 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_keeps_lines_consistent() {
|
||||
let mut f = two(0.0);
|
||||
let c = f.add_point(FreeformPoint::new(Point::new(200.0, 0.0), red()));
|
||||
assert_eq!(f.connect(0, 1), Some(0));
|
||||
assert_eq!(f.connect(1, c), Some(0), "extends the line ending at 1");
|
||||
assert_eq!(f.lines, vec![vec![0, 1, 2]]);
|
||||
let mid = f.split_line(0, 0, 0.5).unwrap();
|
||||
assert_eq!((mid, &f.lines[0]), (3, &vec![0, 3, 1, 2]));
|
||||
let p = f.points[3];
|
||||
assert!(p.at.y.abs() < 1e-9 && p.at.x > 0.0 && p.at.x < 100.0 && (p.opacity - 0.75).abs() < 1e-6, "on the curve, half way: {p:?}");
|
||||
assert!(f.remove_point(1));
|
||||
assert_eq!(f.lines, vec![vec![0, 2, 1]]);
|
||||
assert!(f.add_line(vec![0, 9]).is_err() && f.add_line(vec![0]).is_err());
|
||||
f.remove_point(0);
|
||||
f.remove_point(0);
|
||||
assert!(f.lines.is_empty() && f.points.len() == 1);
|
||||
assert!(!f.remove_point(0), "the last point stays");
|
||||
assert_eq!(f.stops().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_and_mode() {
|
||||
let f: Freeform = serde_json::from_str(r#"{"points":[{"at":{"x":1.0,"y":2.0},"color":{"model":"rgb","r":1.0,"g":0.0,"b":0.0}}]}"#).unwrap();
|
||||
assert_eq!((f.points[0].opacity, f.points[0].spread, f.mode), (1.0, DEFAULT_SPREAD, FreeformMode::Points));
|
||||
let s = serde_json::to_string(&f).unwrap();
|
||||
assert!(!s.contains("\"lines\"") && !s.contains("\"mode\""), "{s}");
|
||||
assert_eq!(FreeformMode::parse("LINES"), Some(FreeformMode::Lines));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
//! Gradients: definitions (swatch-able) and their placement on an object.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{Affine, Point, Rect, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Color;
|
||||
use crate::freeform::Freeform;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GradientKind {
|
||||
#[default]
|
||||
Linear,
|
||||
Radial,
|
||||
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
|
||||
/// Freeform gradients: colour points and lines (see [`crate::freeform`]).
|
||||
Freeform,
|
||||
}
|
||||
|
||||
impl GradientKind {
|
||||
/// Parse a kind name (`linear`, `radial`, `freeform`; any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GradientKind::Linear => "Linear",
|
||||
@@ -24,7 +31,7 @@ impl GradientKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
pub offset: f32,
|
||||
@@ -34,6 +41,27 @@ pub struct GradientStop {
|
||||
/// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5.
|
||||
#[serde(default = "half")]
|
||||
pub midpoint: f32,
|
||||
/// The global or spot swatch this stop's colour is linked to (edits to the swatch recolour it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swatch: Option<String>,
|
||||
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
|
||||
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
|
||||
pub tint: f32,
|
||||
}
|
||||
|
||||
impl GradientStop {
|
||||
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
|
||||
pub fn new(offset: f32, color: Color) -> Self {
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
|
||||
}
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
|
||||
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
|
||||
self.color = color;
|
||||
(self.swatch, self.tint) = match link {
|
||||
Some((n, t)) => (Some(n), t),
|
||||
None => (None, 1.0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
@@ -52,19 +80,18 @@ pub struct Gradient {
|
||||
impl Default for Gradient {
|
||||
/// Illustrator's default "White, Black" gradient.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
}
|
||||
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
|
||||
}
|
||||
}
|
||||
|
||||
impl Gradient {
|
||||
/// Colour and opacity at `t` (honours midpoints).
|
||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||
self.sample_with(t, Color::lerp)
|
||||
}
|
||||
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
|
||||
/// model rather than display RGB).
|
||||
pub fn sample_with(&self, t: f32, mix: impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) {
|
||||
let stops = &self.stops;
|
||||
if stops.is_empty() {
|
||||
return (Color::BLACK, 1.0);
|
||||
@@ -80,26 +107,29 @@ impl Gradient {
|
||||
// Map through the midpoint: u=mid → 0.5.
|
||||
let m = a.midpoint.clamp(0.01, 0.99);
|
||||
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
|
||||
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
|
||||
return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v);
|
||||
}
|
||||
}
|
||||
let l = stops.last().unwrap();
|
||||
(l.color, l.opacity)
|
||||
match stops.last() {
|
||||
Some(l) => (l.color, l.opacity),
|
||||
None => (Color::BLACK, 1.0),
|
||||
}
|
||||
}
|
||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
|
||||
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, s) in self.stops.iter().enumerate() {
|
||||
out.push((s.offset, s.color, s.opacity));
|
||||
if let Some(n) = self.stops.get(i + 1)
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
|
||||
}
|
||||
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
||||
/// on the gradient's own stops).
|
||||
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
||||
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
||||
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
let (c, o) = self.sample(t);
|
||||
out.push((t, c, o));
|
||||
}
|
||||
}
|
||||
out
|
||||
(t, c, o, Some(s.midpoint))
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
@@ -110,6 +140,121 @@ impl Gradient {
|
||||
pub fn sort(&mut self) {
|
||||
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
}
|
||||
/// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient
|
||||
/// of its own running 0 → 1: the same colours (midpoints become stops, as in
|
||||
/// [`Self::expanded_stops`]). One colour throughout when `from == to`.
|
||||
pub fn span(&self, from: f32, to: f32) -> Gradient {
|
||||
if to < from {
|
||||
let mut g = self.span(to, from);
|
||||
g.reverse();
|
||||
return g;
|
||||
}
|
||||
let stop = |offset: f32, t: f32| {
|
||||
let (color, opacity) = self.sample(t);
|
||||
GradientStop { opacity, ..GradientStop::new(offset, color) }
|
||||
};
|
||||
let d = to - from;
|
||||
let mut stops = vec![stop(0.0, from)];
|
||||
if d > 1e-6 {
|
||||
stops.extend(
|
||||
self.expanded_stops()
|
||||
.into_iter()
|
||||
.filter(|(t, ..)| *t > from && *t < to)
|
||||
.map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }),
|
||||
);
|
||||
}
|
||||
stops.push(stop(1.0, to));
|
||||
Gradient { kind: GradientKind::Linear, stops }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- stop editing (the Gradient panel, the annotator and agents share these) ----------
|
||||
|
||||
/// Fewest stops a gradient keeps: deleting a stop never goes below this.
|
||||
pub const MIN_STOPS: usize = 2;
|
||||
|
||||
/// Put `s` among `stops` (sorted by offset, after stops at the same offset). Returns its index.
|
||||
fn place_stop(stops: &mut Vec<GradientStop>, s: GradientStop) -> usize {
|
||||
let i = stops.iter().position(|o| o.offset > s.offset).unwrap_or(stops.len());
|
||||
stops.insert(i, s);
|
||||
i
|
||||
}
|
||||
|
||||
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
|
||||
/// the new stop's index.
|
||||
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let offset = offset.clamp(0.0, 1.0);
|
||||
let (color, opacity) = g.sample(offset);
|
||||
let mut stops = g.stops.clone();
|
||||
let i = place_stop(&mut stops, GradientStop { opacity, ..GradientStop::new(offset, color) });
|
||||
(stops, i)
|
||||
}
|
||||
|
||||
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
|
||||
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
|
||||
if stops.len() <= MIN_STOPS || i >= stops.len() {
|
||||
return None;
|
||||
}
|
||||
let mut v = stops.to_vec();
|
||||
v.remove(i);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
|
||||
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
if i >= v.len() {
|
||||
return (v, i);
|
||||
}
|
||||
let mut s = v.remove(i);
|
||||
s.offset = offset.clamp(0.0, 1.0);
|
||||
let ni = place_stop(&mut v, s);
|
||||
(v, ni)
|
||||
}
|
||||
|
||||
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
|
||||
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
let Some(s) = v.get(i).cloned() else { return (v, i) };
|
||||
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
|
||||
(v, ni)
|
||||
}
|
||||
|
||||
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another), with their swatch
|
||||
/// links and tints; offsets, opacities and midpoints stay. Out-of-range indices leave the stops
|
||||
/// unchanged.
|
||||
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
|
||||
let mut v = stops.to_vec();
|
||||
if a < v.len() && b < v.len() && a != b {
|
||||
let (lo, hi) = v.split_at_mut(a.max(b));
|
||||
let (x, y) = (&mut lo[a.min(b)], &mut hi[0]);
|
||||
std::mem::swap(&mut x.color, &mut y.color);
|
||||
std::mem::swap(&mut x.swatch, &mut y.swatch);
|
||||
std::mem::swap(&mut x.tint, &mut y.tint);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
|
||||
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
|
||||
let mut v = stops.to_vec();
|
||||
if let Some(s) = v.get_mut(i) {
|
||||
s.midpoint = m.clamp(0.13, 0.87);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
|
||||
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
Some(a.offset + (b.offset - a.offset) * a.midpoint)
|
||||
}
|
||||
|
||||
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
|
||||
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
let span = (b.offset - a.offset).max(1e-6);
|
||||
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
|
||||
}
|
||||
|
||||
/// Where the gradient sits on an object, in document coordinates.
|
||||
@@ -120,34 +265,162 @@ pub struct GradientGeom {
|
||||
/// Radial aspect ratio (height / width), 1 = circle.
|
||||
#[serde(default = "one64")]
|
||||
pub aspect: f64,
|
||||
/// A radial gradient's focal point, where its first stop sits (an off-centre radial), in the
|
||||
/// same space; inside the extent ellipse. None: the centre (`start`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focal: Option<Point>,
|
||||
}
|
||||
|
||||
/// How far out a focal point may sit, as a fraction of the extent ellipse.
|
||||
const FOCAL_MAX: f64 = 0.99;
|
||||
|
||||
fn one64() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl GradientGeom {
|
||||
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension.
|
||||
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
|
||||
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
|
||||
let c = b.center();
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
match kind {
|
||||
GradientKind::Radial => {
|
||||
let r = b.width().max(b.height()) / 2.0;
|
||||
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
|
||||
}
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
|
||||
_ => {
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
// Project the box corners onto the direction to cover the whole box.
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0, focal: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.start = a * self.start;
|
||||
self.end = a * self.end;
|
||||
|
||||
/// Map the placement through `a` so the gradient follows its object exactly.
|
||||
///
|
||||
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
|
||||
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
|
||||
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
|
||||
/// new end (on the axis nearest the mapped vector) and aspect.
|
||||
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
|
||||
// The focal point is a point like any other.
|
||||
self.focal = self.focal.map(|f| a * f);
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y);
|
||||
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
|
||||
let start = a * self.start;
|
||||
let u = self.end - self.start;
|
||||
if u.hypot2() < 1e-18 {
|
||||
self.start = start;
|
||||
self.end = start;
|
||||
return;
|
||||
}
|
||||
if kind != GradientKind::Radial {
|
||||
let far = a * self.end;
|
||||
let nrm = perp(lin(perp(u)));
|
||||
self.start = start;
|
||||
self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
|
||||
return;
|
||||
}
|
||||
let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
|
||||
// Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii.
|
||||
let p = lu.x * lu.x + lw.x * lw.x;
|
||||
let q = lu.y * lu.y + lw.y * lw.y;
|
||||
let r = lu.x * lu.y + lw.x * lw.y;
|
||||
let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt();
|
||||
let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt();
|
||||
let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt();
|
||||
let (axis, len, other) = if disc <= 1e-12 * (p + q) {
|
||||
// A circle: every direction is an axis, so keep the mapped vector's.
|
||||
(lu / lu.hypot().max(1e-300), s1, s2)
|
||||
} else {
|
||||
let th = 0.5 * (2.0 * r).atan2(p - q);
|
||||
let e1 = Vec2::new(th.cos(), th.sin());
|
||||
let e2 = perp(e1);
|
||||
if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) }
|
||||
};
|
||||
let axis = if axis.dot(lu) < 0.0 { -axis } else { axis };
|
||||
self.start = start;
|
||||
self.end = start + axis * len;
|
||||
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
|
||||
}
|
||||
|
||||
/// Move the placement from box `from` to box `to`: the start and end keep their position
|
||||
/// relative to the box (along a side `from` has no extent on, their offset from its centre).
|
||||
/// The aspect ratio is kept.
|
||||
pub fn rebase(&mut self, from: Rect, to: Rect) {
|
||||
self.start = rebase_point(self.start, from, to);
|
||||
self.end = rebase_point(self.end, from, to);
|
||||
self.focal = self.focal.map(|f| rebase_point(f, from, to));
|
||||
}
|
||||
|
||||
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial,
|
||||
/// measured from the focal point (each level is the ellipse scaled about the line from the
|
||||
/// focal point to the centre).
|
||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||
let u = self.end - self.start;
|
||||
if u.hypot2() < 1e-18 {
|
||||
return 0.0;
|
||||
}
|
||||
if kind != GradientKind::Radial {
|
||||
return (p - self.start).dot(u) / u.hypot2();
|
||||
}
|
||||
let Some(to_unit) = self.unit_frame().map(|m| m.inverse()) else { return 0.0 };
|
||||
let q = (to_unit * p).to_vec2();
|
||||
let Some(f) = self.focal.map(|f| (to_unit * f).to_vec2()) else { return q.hypot() };
|
||||
// The level t through q: |q - f(1 - t)| = t, the root at t >= 0 (|f| < 1).
|
||||
let d = q - f;
|
||||
let a = f.hypot2() - 1.0;
|
||||
if a.abs() < 1e-12 {
|
||||
return q.hypot();
|
||||
}
|
||||
let b = d.dot(f);
|
||||
(-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a
|
||||
}
|
||||
|
||||
/// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to
|
||||
/// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse).
|
||||
pub fn unit_frame(&self) -> Option<Affine> {
|
||||
let u = self.end - self.start;
|
||||
(u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y]))
|
||||
}
|
||||
|
||||
/// The map that squashes a radial gradient's circle (centre `start`, radius the vector's
|
||||
/// length) into its extent ellipse: by `aspect` across the vector, about the centre.
|
||||
pub fn radial_squash(&self) -> Affine {
|
||||
let angle = (self.end - self.start).atan2();
|
||||
let c = self.start.to_vec2();
|
||||
Affine::translate(c)
|
||||
* Affine::rotate(angle)
|
||||
* Affine::scale_non_uniform(1.0, self.aspect.max(1e-3))
|
||||
* Affine::rotate(-angle)
|
||||
* Affine::translate(-c)
|
||||
}
|
||||
|
||||
/// Where a radial gradient's first stop sits: its focal point, else its centre.
|
||||
pub fn focal_point(&self) -> Point {
|
||||
self.focal.unwrap_or(self.start)
|
||||
}
|
||||
|
||||
/// Set the focal point, pulled inside the extent ellipse; one on the centre (or a gradient
|
||||
/// with no extent) has none.
|
||||
pub fn set_focal(&mut self, f: Option<Point>) {
|
||||
self.focal = f.and_then(|f| {
|
||||
let m = self.unit_frame()?;
|
||||
let q = (m.inverse() * f).to_vec2();
|
||||
let r = q.hypot();
|
||||
(r > 1e-6).then(|| if r > FOCAL_MAX { m * (q * (FOCAL_MAX / r)).to_point() } else { f })
|
||||
});
|
||||
}
|
||||
|
||||
/// After a change of the vector or aspect from `before`, put the focal point back where it
|
||||
/// was relative to the extent ellipse.
|
||||
pub fn keep_focal_from(&mut self, before: &GradientGeom) {
|
||||
let rel = before.focal.zip(before.unit_frame()).map(|(f, m)| m.inverse() * f);
|
||||
self.focal = None;
|
||||
if let (Some(q), Some(m)) = (rel, self.unit_frame()) {
|
||||
self.set_focal(Some(m * q));
|
||||
}
|
||||
}
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
let v = self.end - self.start;
|
||||
@@ -158,6 +431,15 @@ impl GradientGeom {
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side
|
||||
/// `from` has no extent on, its offset from the centre).
|
||||
fn rebase_point(p: Point, from: Rect, to: Rect) -> Point {
|
||||
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
|
||||
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
|
||||
};
|
||||
Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1))
|
||||
}
|
||||
|
||||
/// A gradient applied to a fill or stroke.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientPaint {
|
||||
@@ -170,15 +452,80 @@ pub struct GradientPaint {
|
||||
/// Name of the gradient swatch, if linked.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swatch: Option<String>,
|
||||
/// A freeform gradient's points, in the same space as `geom` (None: placed automatically on
|
||||
/// the object's box each render, coloured along the stops).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub freeform: Option<Freeform>,
|
||||
}
|
||||
|
||||
impl GradientPaint {
|
||||
pub fn new(gradient: Gradient) -> Self {
|
||||
Self { gradient, geom: None, angle: 0.0, swatch: None }
|
||||
Self { gradient, geom: None, angle: 0.0, swatch: None, freeform: None }
|
||||
}
|
||||
/// The freeform gradient drawn on box `bounds`: its own points, else the automatic ones
|
||||
/// (fitted to the box: [`Freeform::auto`] without a shape to keep them inside).
|
||||
pub fn freeform_on(&self, bounds: Rect) -> Cow<'_, Freeform> {
|
||||
match &self.freeform {
|
||||
Some(f) if !f.points.is_empty() => Cow::Borrowed(f),
|
||||
f => Cow::Owned(Freeform {
|
||||
mode: f.as_ref().map_or_else(Default::default, |f| f.mode),
|
||||
..Freeform::auto(bounds, &self.gradient, &|_| true)
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Does it carry its own freeform points (rather than placing them on each render)?
|
||||
pub fn has_freeform_points(&self) -> bool {
|
||||
self.freeform.as_ref().is_some_and(|f| !f.points.is_empty())
|
||||
}
|
||||
/// Give a freeform gradient without points its automatic ones on `bounds`, kept `inside` the
|
||||
/// shape (the Draw mode stays).
|
||||
pub fn seed_freeform(&mut self, bounds: Rect, inside: &dyn Fn(Point) -> bool) {
|
||||
if self.gradient.kind == GradientKind::Freeform && !self.has_freeform_points() {
|
||||
let mode = self.freeform.as_ref().map_or_else(Default::default, |f| f.mode);
|
||||
self.set_freeform(Freeform { mode, ..Freeform::auto(bounds, &self.gradient, inside) });
|
||||
}
|
||||
}
|
||||
/// Set the freeform points, with the stops following their colours (what swatch chips,
|
||||
/// exports without freeform shading and re-seeding on other art show).
|
||||
pub fn set_freeform(&mut self, f: Freeform) {
|
||||
self.gradient.stops = f.stops();
|
||||
self.freeform = Some(f);
|
||||
}
|
||||
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
|
||||
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
|
||||
}
|
||||
/// Is the gradient placed in its paint's space (its vector, and a freeform gradient's points),
|
||||
/// rather than fitted to the object's box on each render?
|
||||
pub fn is_placed(&self) -> bool {
|
||||
self.geom.is_some() && (self.gradient.kind != GradientKind::Freeform || self.has_freeform_points())
|
||||
}
|
||||
/// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms
|
||||
/// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps).
|
||||
/// Freeform gradients get their automatic points.
|
||||
pub fn pin(&mut self, bounds: Rect) {
|
||||
if self.geom.is_none() {
|
||||
self.geom = Some(self.resolve(bounds));
|
||||
}
|
||||
self.seed_freeform(bounds, &|_| true);
|
||||
}
|
||||
/// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]).
|
||||
pub fn rebase(&mut self, from: Rect, to: Rect) {
|
||||
if let Some(g) = &mut self.geom {
|
||||
g.rebase(from, to);
|
||||
}
|
||||
if let Some(f) = &mut self.freeform {
|
||||
f.map_points(|p| rebase_point(p, from, to));
|
||||
}
|
||||
}
|
||||
/// Map a placed gradient (and freeform points) through `a` (see [`GradientGeom::transform`]).
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
if let Some(g) = &mut self.geom {
|
||||
g.transform(a, self.gradient.kind);
|
||||
}
|
||||
if let Some(f) = &mut self.freeform {
|
||||
f.transform(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -221,4 +568,227 @@ mod tests {
|
||||
assert_eq!(g.stops[0].color.to_hex(), "#000000");
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
|
||||
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_90_gives_vertical_vector() {
|
||||
let mut g = linear((0.0, 0.0), (100.0, 0.0));
|
||||
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||||
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
|
||||
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_swaps_start_and_end() {
|
||||
let mut g = linear((0.0, 25.0), (100.0, 25.0));
|
||||
// Reflect across the vertical axis through x = 50.
|
||||
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
|
||||
g.transform(flip, GradientKind::Linear);
|
||||
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
|
||||
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None };
|
||||
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
|
||||
assert!((g.aspect - 0.5).abs() < 1e-9);
|
||||
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
|
||||
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None };
|
||||
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_follow_shear_and_rotation() {
|
||||
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None };
|
||||
let mut m = g;
|
||||
m.transform(shear, kind);
|
||||
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
|
||||
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
|
||||
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
|
||||
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
|
||||
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
|
||||
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None };
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
fn g3() -> Gradient {
|
||||
let mut g = Gradient::default();
|
||||
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_samples_color_and_sorts() {
|
||||
let g = Gradient::default();
|
||||
let (stops, i) = insert_stop(&g, 0.25);
|
||||
assert_eq!(stops.len(), 3);
|
||||
assert_eq!(i, 1);
|
||||
let r = stops[1].color.to_rgb()[0];
|
||||
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
|
||||
let (stops, i) = insert_stop(&g, 2.0);
|
||||
assert_eq!(i, 2);
|
||||
assert_eq!(stops[2].offset, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keeps_two() {
|
||||
let g = g3();
|
||||
let v = remove_stop(&g.stops, 1).unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
assert!(remove_stop(&v, 0).is_none());
|
||||
assert!(remove_stop(&g.stops, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_reorders_and_tracks_index() {
|
||||
let g = g3();
|
||||
let (v, i) = move_stop(&g.stops, 0, 0.8);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(v[1].color.to_hex(), "#ffffff");
|
||||
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
|
||||
let (v, i) = move_stop(&g.stops, 1, -3.0);
|
||||
// Clamped to 0 and placed after the existing stop at 0.
|
||||
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_the_stop_to_a_new_offset() {
|
||||
let g = g3();
|
||||
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
|
||||
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
|
||||
assert_eq!(v[1], g.stops[1], "the original stays");
|
||||
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_only_the_colours() {
|
||||
let mut g = g3();
|
||||
g.stops[0].opacity = 0.25;
|
||||
let v = swap_stop_colors(&g.stops, 0, 1);
|
||||
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
|
||||
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
|
||||
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_math() {
|
||||
let g = g3();
|
||||
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
|
||||
assert_eq!(midpoint_pos(&g.stops, 2), None);
|
||||
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
|
||||
assert!((m - 0.2).abs() < 1e-5);
|
||||
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
|
||||
let v = set_midpoint(&g.stops, 0, 0.99);
|
||||
assert_eq!(v[0].midpoint, 0.87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_keeps_relative_positions() {
|
||||
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None };
|
||||
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
|
||||
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
|
||||
// A box without height (a horizontal line) keeps the offset from its centre line.
|
||||
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None };
|
||||
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
|
||||
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
|
||||
// Unplaced paints have nothing to move.
|
||||
let mut p = GradientPaint::new(Gradient::default());
|
||||
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
|
||||
assert_eq!(p.geom, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_fixes_the_fit_once() {
|
||||
let mut p = GradientPaint::new(Gradient::default());
|
||||
p.angle = 90.0;
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
p.pin(b);
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
}
|
||||
|
||||
/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre.
|
||||
fn off_centre() -> GradientGeom {
|
||||
let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None };
|
||||
g.set_focal(Some(Point::new(160.0, 200.0)));
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_round_trips_through_serde_and_defaults_to_none() {
|
||||
let g = off_centre();
|
||||
let s = serde_json::to_string(&g).unwrap();
|
||||
assert!(s.contains("\"focal\""), "{s}");
|
||||
assert_eq!(serde_json::from_str::<GradientGeom>(&s).unwrap(), g);
|
||||
// Placements saved before focal points existed load centred, and centred ones write none.
|
||||
let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap();
|
||||
assert_eq!((old.focal, old.aspect), (None, 1.0));
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("focal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_follows_transforms_and_rebases() {
|
||||
let mut g = off_centre();
|
||||
let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
let f = a * g.focal.unwrap();
|
||||
g.transform(a, GradientKind::Radial);
|
||||
assert!(close(g.focal.unwrap(), f), "{g:?}");
|
||||
// Still at the same parameter: the first stop.
|
||||
assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9);
|
||||
let mut h = off_centre();
|
||||
h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_runs_from_the_focal_point_to_the_ellipse() {
|
||||
let g = off_centre();
|
||||
let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y));
|
||||
assert!(t(160.0, 200.0).abs() < 1e-9);
|
||||
// The extent circle is the last stop all round.
|
||||
for a in [0.0f64, 1.0, 2.5, 4.0] {
|
||||
let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin());
|
||||
assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}");
|
||||
}
|
||||
// The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point
|
||||
// (0.4 of the radius off), with a radius of 2/7.
|
||||
assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0));
|
||||
assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier");
|
||||
assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() {
|
||||
let mut g = off_centre();
|
||||
g.set_focal(Some(Point::new(500.0, 200.0)));
|
||||
assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}");
|
||||
g.set_focal(Some(Point::new(200.0, 200.0)));
|
||||
assert_eq!(g.focal, None, "on the centre: centred");
|
||||
// A new vector and aspect keep it in its place in the ellipse.
|
||||
let before = off_centre();
|
||||
let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None };
|
||||
h.keep_focal_from(&before);
|
||||
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,358 @@
|
||||
//! Colour harmony rules (the Color Guide panel).
|
||||
//! Colour harmony rules, the variation grid of the Color Guide panel and the five-colour themes of
|
||||
//! the Color Themes panel (our own colour math).
|
||||
|
||||
use crate::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::recolor::Palette;
|
||||
use crate::{Color, keep_model};
|
||||
|
||||
/// A harmony rule: a group of colours built from a base colour, base first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Harmony {
|
||||
Complementary,
|
||||
Complementary2,
|
||||
SplitComplementary,
|
||||
LeftComplement,
|
||||
RightComplement,
|
||||
Analogous,
|
||||
Triad,
|
||||
Tetrad,
|
||||
Square,
|
||||
Analogous2,
|
||||
Monochromatic,
|
||||
Shades,
|
||||
Triad,
|
||||
Triad2,
|
||||
Triad3,
|
||||
Tetrad,
|
||||
Tetrad2,
|
||||
Tetrad3,
|
||||
Compound,
|
||||
Compound2,
|
||||
HighContrast,
|
||||
HighContrast2,
|
||||
HighContrast3,
|
||||
Pentagram,
|
||||
}
|
||||
|
||||
/// One colour of a rule: the base turned by `turn` degrees of hue, its saturation scaled by `sat`
|
||||
/// and its brightness by `bright` (or flipped to the contrasting brightness).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Tone {
|
||||
turn: f32,
|
||||
sat: f32,
|
||||
bright: Bright,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Bright {
|
||||
Times(f32),
|
||||
/// Dark for a light base, light for a dark one ([`contrast`]).
|
||||
Contrast,
|
||||
}
|
||||
|
||||
const fn sv(turn: f32, sat: f32, bright: f32) -> Tone {
|
||||
Tone { turn, sat, bright: Bright::Times(bright) }
|
||||
}
|
||||
/// The base's hue turned by `turn`.
|
||||
const fn t(turn: f32) -> Tone {
|
||||
sv(turn, 1.0, 1.0)
|
||||
}
|
||||
/// A softer (half as saturated) partner.
|
||||
const fn soft(turn: f32) -> Tone {
|
||||
sv(turn, 0.5, 1.0)
|
||||
}
|
||||
/// A deeper (darker) partner.
|
||||
const fn deep(turn: f32) -> Tone {
|
||||
sv(turn, 1.0, 0.6)
|
||||
}
|
||||
/// A partner of contrasting brightness.
|
||||
const fn hc(turn: f32) -> Tone {
|
||||
Tone { turn, sat: 1.0, bright: Bright::Contrast }
|
||||
}
|
||||
|
||||
/// The brightness that contrasts with `v`.
|
||||
fn contrast(v: f32) -> f32 {
|
||||
if v >= 0.5 { v * 0.3 } else { 0.7 + 0.3 * v }
|
||||
}
|
||||
|
||||
impl Harmony {
|
||||
pub const ALL: [Harmony; 9] = [
|
||||
pub const ALL: [Harmony; 21] = [
|
||||
Harmony::Complementary,
|
||||
Harmony::Complementary2,
|
||||
Harmony::SplitComplementary,
|
||||
Harmony::LeftComplement,
|
||||
Harmony::RightComplement,
|
||||
Harmony::Analogous,
|
||||
Harmony::Triad,
|
||||
Harmony::Tetrad,
|
||||
Harmony::Square,
|
||||
Harmony::Analogous2,
|
||||
Harmony::Monochromatic,
|
||||
Harmony::Shades,
|
||||
Harmony::Triad,
|
||||
Harmony::Triad2,
|
||||
Harmony::Triad3,
|
||||
Harmony::Tetrad,
|
||||
Harmony::Tetrad2,
|
||||
Harmony::Tetrad3,
|
||||
Harmony::Compound,
|
||||
Harmony::Compound2,
|
||||
Harmony::HighContrast,
|
||||
Harmony::HighContrast2,
|
||||
Harmony::HighContrast3,
|
||||
Harmony::Pentagram,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Harmony::Complementary => "Complementary",
|
||||
Harmony::Complementary2 => "Complementary 2",
|
||||
Harmony::SplitComplementary => "Split Complementary",
|
||||
Harmony::LeftComplement => "Left Complement",
|
||||
Harmony::RightComplement => "Right Complement",
|
||||
Harmony::Analogous => "Analogous",
|
||||
Harmony::Triad => "Triad",
|
||||
Harmony::Tetrad => "Tetrad",
|
||||
Harmony::Square => "Square",
|
||||
Harmony::Analogous2 => "Analogous 2",
|
||||
Harmony::Monochromatic => "Monochromatic",
|
||||
Harmony::Shades => "Shades",
|
||||
Harmony::Triad => "Triad",
|
||||
Harmony::Triad2 => "Triad 2",
|
||||
Harmony::Triad3 => "Triad 3",
|
||||
Harmony::Tetrad => "Tetrad",
|
||||
Harmony::Tetrad2 => "Tetrad 2",
|
||||
Harmony::Tetrad3 => "Tetrad 3",
|
||||
Harmony::Compound => "Compound",
|
||||
Harmony::Compound2 => "Compound 2",
|
||||
Harmony::HighContrast => "High Contrast",
|
||||
Harmony::HighContrast2 => "High Contrast 2",
|
||||
Harmony::HighContrast3 => "High Contrast 3",
|
||||
Harmony::Pentagram => "Pentagram",
|
||||
}
|
||||
}
|
||||
/// Colours of the harmony, base colour first.
|
||||
|
||||
/// The rule's id in commands: its label in camelCase ("splitComplementary", "triad2").
|
||||
pub fn id(self) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, w) in self.label().split(' ').enumerate() {
|
||||
let mut c = w.chars();
|
||||
if let Some(f) = c.next() {
|
||||
if i == 0 {
|
||||
out.extend(f.to_lowercase());
|
||||
} else {
|
||||
out.extend(f.to_uppercase());
|
||||
}
|
||||
out.push_str(c.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A rule from its id or label (case and spaces ignored).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let key = |x: &str| x.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase();
|
||||
let want = key(s);
|
||||
Self::ALL.into_iter().find(|h| key(h.label()) == want)
|
||||
}
|
||||
|
||||
fn tones(self) -> &'static [Tone] {
|
||||
match self {
|
||||
Harmony::Complementary => const { &[t(0.0), t(180.0)] },
|
||||
Harmony::Complementary2 => const { &[t(0.0), soft(0.0), t(180.0), soft(180.0)] },
|
||||
Harmony::SplitComplementary => const { &[t(0.0), t(150.0), t(210.0)] },
|
||||
Harmony::LeftComplement => const { &[t(0.0), t(-30.0), t(150.0)] },
|
||||
Harmony::RightComplement => const { &[t(0.0), t(30.0), t(210.0)] },
|
||||
Harmony::Analogous => const { &[t(0.0), t(30.0), t(-30.0)] },
|
||||
Harmony::Analogous2 => const { &[t(0.0), t(30.0), t(60.0), t(-30.0), t(-60.0)] },
|
||||
Harmony::Monochromatic => const { &[t(0.0), sv(0.0, 0.5, 1.0), sv(0.0, 1.0, 0.6), sv(0.0, 0.25, 1.0), sv(0.0, 1.0, 0.35)] },
|
||||
Harmony::Shades => const { &[t(0.0), sv(0.0, 1.0, 0.8), sv(0.0, 1.0, 0.6), sv(0.0, 1.0, 0.4), sv(0.0, 1.0, 0.2)] },
|
||||
Harmony::Triad => const { &[t(0.0), t(120.0), t(240.0)] },
|
||||
Harmony::Triad2 => const { &[t(0.0), t(120.0), soft(120.0), t(240.0), soft(240.0)] },
|
||||
Harmony::Triad3 => const { &[t(0.0), t(120.0), deep(120.0), t(240.0), deep(240.0)] },
|
||||
Harmony::Tetrad => const { &[t(0.0), t(60.0), t(180.0), t(240.0)] },
|
||||
Harmony::Tetrad2 => const { &[t(0.0), t(90.0), t(180.0), t(270.0)] },
|
||||
Harmony::Tetrad3 => const { &[t(0.0), t(30.0), t(180.0), t(210.0)] },
|
||||
Harmony::Compound => const { &[t(0.0), t(40.0), t(160.0), t(200.0)] },
|
||||
Harmony::Compound2 => const { &[t(0.0), t(-40.0), t(160.0), t(200.0)] },
|
||||
Harmony::HighContrast => const { &[t(0.0), hc(180.0)] },
|
||||
Harmony::HighContrast2 => const { &[t(0.0), hc(0.0), t(180.0), hc(180.0)] },
|
||||
Harmony::HighContrast3 => const { &[t(0.0), hc(120.0), hc(240.0)] },
|
||||
Harmony::Pentagram => const { &[t(0.0), t(72.0), t(144.0), t(216.0), t(288.0)] },
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule's colours in the base colour's model, the base colour itself first.
|
||||
pub fn apply(self, base: Color) -> Vec<Color> {
|
||||
let [h, s, v] = base.to_hsb();
|
||||
let hue = |d: f32| Color::from_hsb(h + d, s, v);
|
||||
std::iter::once(base)
|
||||
.chain(self.tones()[1..].iter().map(|tone| {
|
||||
let b = match tone.bright {
|
||||
Bright::Times(f) => v * f,
|
||||
Bright::Contrast => contrast(v),
|
||||
};
|
||||
keep_model(base, Color::from_hsb(h + tone.turn, (s * tone.sat).clamp(0.0, 1.0), b.clamp(0.0, 1.0)))
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A [`THEME_SIZE`]-colour theme from `base`: the rule's colours, each followed by the lighter or
|
||||
/// darker variations of it that fill the remaining places (the rule's first colours get them
|
||||
/// first). The base colour comes first, every colour in its model.
|
||||
pub fn theme(self, base: Color) -> Vec<Color> {
|
||||
let colors = self.apply(base);
|
||||
let n = colors.len();
|
||||
let mut rows: Vec<Vec<Color>> = colors.iter().map(|c| vec![*c]).collect();
|
||||
let opts = GuideOptions::default();
|
||||
for k in 0..THEME_SIZE.saturating_sub(n) {
|
||||
let (i, pass) = (k % n, k / n);
|
||||
// Alternate lighter and darker along the rule and from one pass to the next.
|
||||
let step = if (i + pass) % 2 == 0 { 3 } else { -3 };
|
||||
rows[i].push(variation(colors[i], &opts, step));
|
||||
}
|
||||
rows.into_iter().flatten().take(THEME_SIZE).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of colours in a colour theme.
|
||||
pub const THEME_SIZE: usize = 5;
|
||||
|
||||
/// Move colour `i` of a harmony on the hue and saturation wheel to `hsb` (hue in degrees,
|
||||
/// saturation and brightness 0..1), keeping its model. With `linked` the other colours keep their
|
||||
/// relation to it: they turn by the same hue, scale their saturation alike and shift their
|
||||
/// brightness by as much.
|
||||
pub fn move_on_wheel(colors: &mut [Color], i: usize, hsb: [f32; 3], linked: bool) {
|
||||
let Some(&old) = colors.get(i) else { return };
|
||||
let [h0, s0, v0] = old.to_hsb();
|
||||
let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0);
|
||||
for (j, c) in colors.iter_mut().enumerate() {
|
||||
let new = if j == i {
|
||||
hsb
|
||||
} else if linked {
|
||||
let [h, s, v] = c.to_hsb();
|
||||
[h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)]
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
*c = keep_model(*c, Color::from_hsb(new[0], new[1], new[2]));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the variation grid varies: towards black and white, towards cool and warm, or towards
|
||||
/// grey and full saturation.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Variation {
|
||||
#[default]
|
||||
TintsShades,
|
||||
WarmCool,
|
||||
VividMuted,
|
||||
}
|
||||
|
||||
impl Variation {
|
||||
pub const ALL: [Variation; 3] = [Variation::TintsShades, Variation::WarmCool, Variation::VividMuted];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Harmony::Complementary => vec![base, hue(180.0)],
|
||||
Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)],
|
||||
Harmony::LeftComplement => vec![base, hue(150.0)],
|
||||
Harmony::RightComplement => vec![base, hue(210.0)],
|
||||
Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)],
|
||||
Harmony::Triad => vec![base, hue(120.0), hue(240.0)],
|
||||
Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)],
|
||||
Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)],
|
||||
Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(),
|
||||
Variation::TintsShades => "tintsShades",
|
||||
Variation::WarmCool => "warmCool",
|
||||
Variation::VividMuted => "vividMuted",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
/// The ends of the grid: (left, right).
|
||||
pub fn sides(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Variation::TintsShades => ("Shades", "Tints"),
|
||||
Variation::WarmCool => ("Cool", "Warm"),
|
||||
Variation::VividMuted => ("Muted", "Vivid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tints and shades row for the Color Guide variation grid.
|
||||
pub fn variations(base: Color, steps: usize) -> Vec<Color> {
|
||||
let n = steps.max(1) as f32;
|
||||
(0..steps)
|
||||
.map(|i| {
|
||||
let t = (i as f32 + 1.0) / (n + 1.0);
|
||||
if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }
|
||||
})
|
||||
.collect()
|
||||
/// The Color Guide's options: variation kind, steps on each side and how far they reach.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GuideOptions {
|
||||
pub variation: Variation,
|
||||
/// Variations on each side of the harmony colour (1..=20).
|
||||
pub steps: u32,
|
||||
/// How far the outermost variations go, 0..=100 (Less … More).
|
||||
pub amount: f32,
|
||||
}
|
||||
|
||||
impl Default for GuideOptions {
|
||||
fn default() -> Self {
|
||||
Self { variation: Variation::TintsShades, steps: 4, amount: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl GuideOptions {
|
||||
pub const MAX_STEPS: u32 = 20;
|
||||
|
||||
/// The options with `steps` and `amount` clamped to their ranges.
|
||||
pub fn clamped(self) -> Self {
|
||||
Self { steps: self.steps.clamp(1, Self::MAX_STEPS), amount: self.amount.clamp(0.0, 100.0), ..self }
|
||||
}
|
||||
/// The share of the way to the target that the outermost steps reach (10% … 90%).
|
||||
fn reach(&self) -> f32 {
|
||||
0.1 + 0.8 * self.amount.clamp(0.0, 100.0) / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Variation of `c` at `step` (−steps..=steps: negative towards shades/cool/muted, positive towards
|
||||
/// tints/warm/vivid; 0 is `c` itself), in `c`'s model.
|
||||
pub fn variation(c: Color, opts: &GuideOptions, step: i32) -> Color {
|
||||
if step == 0 {
|
||||
return c;
|
||||
}
|
||||
let f = (step.unsigned_abs() as f32 / opts.steps.max(1) as f32).min(1.0) * opts.reach();
|
||||
let v = match opts.variation {
|
||||
Variation::TintsShades => c.lerp(if step < 0 { &Color::BLACK } else { &Color::WHITE }, f),
|
||||
Variation::WarmCool => {
|
||||
let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) };
|
||||
c.lerp(&target, f * 0.6)
|
||||
}
|
||||
Variation::VividMuted => {
|
||||
let [h, s, b] = c.to_hsb();
|
||||
let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f };
|
||||
Color::from_hsb(h, s2.clamp(0.0, 1.0), b)
|
||||
}
|
||||
};
|
||||
keep_model(c, v)
|
||||
}
|
||||
|
||||
/// The Color Guide for a base colour: the harmony colours and, per colour, its row of variations
|
||||
/// from `-steps` to `+steps` (the colour itself in the centre column).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Guide {
|
||||
pub colors: Vec<Color>,
|
||||
pub grid: Vec<Vec<Color>>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
pub fn new(base: Color, rule: Harmony, opts: &GuideOptions) -> Self {
|
||||
let opts = opts.clamped();
|
||||
let n = opts.steps as i32;
|
||||
let colors = rule.apply(base);
|
||||
let grid = colors.iter().map(|c| (-n..=n).map(|step| variation(*c, &opts, step)).collect()).collect();
|
||||
Self { colors, grid }
|
||||
}
|
||||
/// Index of the centre column (the harmony colours) in each grid row.
|
||||
pub fn centre(&self) -> usize {
|
||||
self.grid.first().map_or(0, |r| r.len() / 2)
|
||||
}
|
||||
|
||||
/// The guide limited to `palette` (Limit to Library): every harmony colour and variation snaps
|
||||
/// to the palette's nearest colour (CIEDE2000), in that colour's own model.
|
||||
pub fn limited(mut self, palette: &Palette) -> Self {
|
||||
if palette.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let centre = self.centre();
|
||||
for (row, c) in self.grid.iter_mut().zip(&mut self.colors) {
|
||||
for v in row.iter_mut() {
|
||||
*v = palette.nearest(*v);
|
||||
}
|
||||
// The centre column is the harmony colour itself.
|
||||
*c = row[centre];
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -78,6 +364,144 @@ mod tests {
|
||||
let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0));
|
||||
assert_eq!(c[1].to_hex(), "#00ffff");
|
||||
assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00");
|
||||
assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6);
|
||||
assert_eq!(Harmony::Pentagram.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hsb()[0].round(), 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_rule_puts_the_base_first() {
|
||||
let counts = [2, 4, 3, 3, 3, 3, 5, 5, 5, 3, 5, 5, 4, 4, 4, 4, 4, 2, 4, 3, 5];
|
||||
assert_eq!(Harmony::ALL.len(), counts.len());
|
||||
for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05), Color::gray(0.4)] {
|
||||
for (h, n) in Harmony::ALL.into_iter().zip(counts) {
|
||||
let c = h.apply(base);
|
||||
assert_eq!(c.len(), n, "{}", h.label());
|
||||
assert_eq!(c[0], base, "{}: the base comes first, in its own model", h.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harmonies_keep_the_base_model() {
|
||||
let base = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
for h in Harmony::ALL {
|
||||
assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label());
|
||||
}
|
||||
let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { panic!("a CMYK complement") };
|
||||
assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}");
|
||||
let g = Guide::new(Color::gray(0.4), Harmony::Triad, &GuideOptions::default());
|
||||
assert!(g.grid.iter().flatten().all(|c| matches!(c, Color::Gray { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_parse_by_id_and_label() {
|
||||
for h in Harmony::ALL {
|
||||
assert_eq!(Harmony::parse(&h.id()), Some(h));
|
||||
assert_eq!(Harmony::parse(h.label()), Some(h));
|
||||
}
|
||||
assert_eq!(Harmony::SplitComplementary.id(), "splitComplementary");
|
||||
assert_eq!(Harmony::HighContrast3.id(), "highContrast3");
|
||||
assert_eq!(Harmony::parse("square"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_contrast_flips_brightness() {
|
||||
let light = Harmony::HighContrast.apply(Color::rgb(0.9, 0.8, 0.2));
|
||||
assert!(light[1].to_hsb()[2] < 0.5);
|
||||
let dark = Harmony::HighContrast.apply(Color::rgb(0.2, 0.1, 0.05));
|
||||
assert!(dark[1].to_hsb()[2] > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variations_step_out_from_the_colour() {
|
||||
let c = Color::rgb(0.5, 0.5, 0.5);
|
||||
let o = GuideOptions::default();
|
||||
assert_eq!(variation(c, &o, 0), c, "step 0 is the colour");
|
||||
assert!(variation(c, &o, 4).to_rgb()[0] > variation(c, &o, 2).to_rgb()[0]);
|
||||
assert!(variation(c, &o, -4).to_rgb()[0] < 0.5);
|
||||
let red = Color::rgb(1.0, 0.2, 0.2);
|
||||
let vm = GuideOptions { variation: Variation::VividMuted, ..o };
|
||||
assert!(variation(red, &vm, -2).to_hsb()[1] < red.to_hsb()[1]);
|
||||
let warm = variation(c, &GuideOptions { variation: Variation::WarmCool, ..o }, 2).to_rgb();
|
||||
assert!(warm[0] > warm[2]);
|
||||
// More variation reaches further.
|
||||
let more = GuideOptions { amount: 100.0, ..o };
|
||||
assert!(variation(c, &more, -4).to_rgb()[0] < variation(c, &o, -4).to_rgb()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_rows_hold_the_colour_in_the_centre() {
|
||||
let base = Color::rgb8(30, 120, 200);
|
||||
let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 3, ..Default::default() });
|
||||
assert_eq!(g.colors.len(), 3);
|
||||
assert!(g.grid.iter().all(|r| r.len() == 7));
|
||||
assert_eq!(g.centre(), 3);
|
||||
for (row, c) in g.grid.iter().zip(&g.colors) {
|
||||
assert_eq!(row[3], *c);
|
||||
}
|
||||
// Steps are clamped to 1..=20.
|
||||
let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 99, ..Default::default() });
|
||||
assert_eq!(g.grid[0].len(), 41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limited_guide_only_holds_palette_colours() {
|
||||
let lib =
|
||||
[Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5), Color::rgb8(250, 240, 200), Color::lab(30.0, 10.0, -40.0)];
|
||||
let palette = Palette::new(lib);
|
||||
for rule in Harmony::ALL {
|
||||
let g = Guide::new(Color::rgb8(200, 60, 40), rule, &GuideOptions::default()).limited(&palette);
|
||||
assert!(g.grid.iter().flatten().chain(&g.colors).all(|c| lib.contains(c)), "{}", rule.label());
|
||||
for (row, c) in g.grid.iter().zip(&g.colors) {
|
||||
assert_eq!(row[g.centre()], *c, "{}: the harmony colour stays in the centre", rule.label());
|
||||
}
|
||||
}
|
||||
// The nearest colour wins: a pale yellow snaps to the cream and its darkest shade to the grey.
|
||||
let g = Guide::new(Color::rgb(0.95, 0.9, 0.75), Harmony::Complementary, &GuideOptions::default()).limited(&palette);
|
||||
assert_eq!(g.colors[0], lib[3]);
|
||||
assert_eq!(g.grid[0][0], lib[2]);
|
||||
// An empty palette limits nothing.
|
||||
let free = Guide::new(Color::rgb(0.8, 0.1, 0.1), Harmony::Triad, &GuideOptions::default());
|
||||
assert_eq!(free.clone().limited(&Palette::default()), free);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themes_have_five_colours_base_first() {
|
||||
for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05)] {
|
||||
for rule in Harmony::ALL {
|
||||
let t = rule.theme(base);
|
||||
assert_eq!(t.len(), THEME_SIZE, "{}", rule.label());
|
||||
assert_eq!(t[0], base, "{}", rule.label());
|
||||
assert!(t.iter().all(|c| c.model() == base.model()), "{}: colours keep the base model", rule.label());
|
||||
}
|
||||
}
|
||||
// A two-colour rule fills in variations next to the colour they vary.
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let t = Harmony::Complementary.theme(red);
|
||||
assert_eq!(t[3].to_hex(), "#00ffff", "the complement follows the base's variations");
|
||||
let l = |c: &Color| c.to_lab().l;
|
||||
assert!(l(&t[1]) > l(&t[0]) && l(&t[2]) < l(&t[0]), "the base, a lighter and a darker red");
|
||||
assert!(l(&t[4]) < l(&t[3]));
|
||||
for (i, a) in t.iter().enumerate() {
|
||||
assert!(t[i + 1..].iter().all(|b| b.to_hex() != a.to_hex()), "{} repeats", a.to_hex());
|
||||
}
|
||||
// Five-colour rules are themes as they are.
|
||||
assert_eq!(Harmony::Pentagram.theme(red), Harmony::Pentagram.apply(red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_linked_colour_moves_the_others_alike() {
|
||||
let mut c = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)];
|
||||
move_on_wheel(&mut c, 0, [120.0, 1.0, 1.0], false);
|
||||
assert_eq!(c[0].to_hex(), "#00ff00");
|
||||
assert_eq!(c[1].to_hex(), "#ffff00", "unlinked: only that colour moves");
|
||||
move_on_wheel(&mut c, 0, [240.0, 0.5, 1.0], true);
|
||||
assert_eq!(c[0].to_hex(), "#8080ff");
|
||||
let [h, s, v] = c[1].to_hsb();
|
||||
assert!((h - 180.0).abs() < 0.5 && (s - 0.5).abs() < 0.01 && v > 0.99, "it turned and paled as much: {h} {s} {v}");
|
||||
assert!(matches!(c[2], Color::Cmyk { .. }), "colours keep their model");
|
||||
// An index past the end moves nothing.
|
||||
let before = c;
|
||||
move_on_wheel(&mut c, 5, [0.0, 0.0, 0.0], true);
|
||||
assert_eq!(c, before);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,52 @@
|
||||
//! Colour, paint and blend-mode types for VectorCraft.
|
||||
//!
|
||||
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so
|
||||
//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
|
||||
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, Lab; HSB is a UI view of RGB),
|
||||
//! so documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod blend;
|
||||
pub mod cms;
|
||||
pub mod freeform;
|
||||
pub mod gradient;
|
||||
pub mod harmony;
|
||||
pub mod libraries;
|
||||
pub mod palette_io;
|
||||
pub mod recolor;
|
||||
pub mod swatch;
|
||||
|
||||
pub use blend::BlendMode;
|
||||
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
|
||||
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
pub use libraries::SwatchLibrary;
|
||||
pub use swatch::{Swatch, SwatchGroup, default_swatches};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A colour in its authoring model. Components are 0..=1.
|
||||
/// A colour in its authoring model. Components are 0..=1, except Lab's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "model", rename_all = "lowercase")]
|
||||
pub enum Color {
|
||||
Rgb { r: f32, g: f32, b: f32 },
|
||||
Cmyk { c: f32, m: f32, y: f32, k: f32 },
|
||||
Gray { k: f32 },
|
||||
Rgb {
|
||||
r: f32,
|
||||
g: f32,
|
||||
b: f32,
|
||||
},
|
||||
Cmyk {
|
||||
c: f32,
|
||||
m: f32,
|
||||
y: f32,
|
||||
k: f32,
|
||||
},
|
||||
Gray {
|
||||
k: f32,
|
||||
},
|
||||
/// CIE L*a*b* (D50): L 0..100, a and b about −128..127. Device independent, the way spot
|
||||
/// colours are defined; it shows and separates through the active colour settings.
|
||||
Lab {
|
||||
l: f32,
|
||||
a: f32,
|
||||
b: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
@@ -47,6 +71,9 @@ impl Color {
|
||||
pub fn gray(k: f32) -> Self {
|
||||
Color::Gray { k }
|
||||
}
|
||||
pub fn lab(l: f32, a: f32, b: f32) -> Self {
|
||||
Color::Lab { l, a, b }
|
||||
}
|
||||
|
||||
/// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is
|
||||
/// converted from the working RGB space, CMYK through the working CMYK profile (relative
|
||||
@@ -57,15 +84,18 @@ impl Color {
|
||||
Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
// Illustrator's Gray is ink percentage: 0 = white, 1 = black.
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)),
|
||||
_ => cms::active().display_rgb(self),
|
||||
}
|
||||
}
|
||||
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula.
|
||||
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula (Lab, being
|
||||
/// device independent, is its sRGB colour).
|
||||
pub fn to_rgb_uncalibrated(&self) -> [f32; 3] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)),
|
||||
}
|
||||
}
|
||||
/// CIE Lab (D50) through the active colour settings.
|
||||
@@ -167,21 +197,115 @@ impl Color {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
|
||||
}
|
||||
/// The RGB inverse expressed in the colour's own model (CMYK through the profile-free
|
||||
/// [`Color::to_cmyk`]; a grey's ink is inverted).
|
||||
pub fn invert_keep_model(&self) -> Self {
|
||||
match *self {
|
||||
Color::Rgb { .. } => self.invert(),
|
||||
Color::Cmyk { .. } => {
|
||||
let [c, m, y, k] = self.invert().to_cmyk();
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
Color::Gray { k } => Color::Gray { k: 1.0 - k },
|
||||
Color::Lab { .. } => self.invert().in_model(cms::Model::Lab),
|
||||
}
|
||||
}
|
||||
/// Complement in the colour's own model: each component becomes (highest + lowest) − itself,
|
||||
/// over R, G, B or over C, M, Y (K kept). For RGB this is the hue turned by 180°; a grey is its
|
||||
/// own complement; Lab turns its hue by 180° (a and b negated).
|
||||
pub fn complement_keep_model(&self) -> Self {
|
||||
let flip = |v: [f32; 3]| {
|
||||
let s = v[0].max(v[1]).max(v[2]) + v[0].min(v[1]).min(v[2]);
|
||||
v.map(|x| (s - x).clamp(0.0, 1.0))
|
||||
};
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => {
|
||||
let [r, g, b] = flip([r, g, b]);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
Color::Cmyk { c, m, y, k } => {
|
||||
let [c, m, y] = flip([c, m, y]);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
Color::Gray { .. } => *self,
|
||||
Color::Lab { l, a, b } => Color::Lab { l, a: -a, b: -b },
|
||||
}
|
||||
}
|
||||
/// Linear interpolation in display RGB.
|
||||
pub fn lerp(&self, other: &Color, t: f32) -> Color {
|
||||
let a = self.to_rgb();
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
}
|
||||
/// The colour model this colour is authored in.
|
||||
pub fn model(&self) -> cms::Model {
|
||||
match self {
|
||||
Color::Rgb { .. } => cms::Model::Rgb,
|
||||
Color::Cmyk { .. } => cms::Model::Cmyk,
|
||||
Color::Gray { .. } => cms::Model::Gray,
|
||||
Color::Lab { .. } => cms::Model::Lab,
|
||||
}
|
||||
}
|
||||
/// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from
|
||||
/// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance, Lab by
|
||||
/// [`Color::to_lab`]); unchanged when it is in `model` already.
|
||||
pub fn in_model(self, model: cms::Model) -> Color {
|
||||
if self.model() == model {
|
||||
return self;
|
||||
}
|
||||
match model {
|
||||
cms::Model::Rgb => {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
Color::rgb(r, g, b)
|
||||
}
|
||||
cms::Model::Cmyk => {
|
||||
let [c, m, y, k] = self.to_cmyk();
|
||||
Color::cmyk(c, m, y, k)
|
||||
}
|
||||
cms::Model::Gray => {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
|
||||
}
|
||||
cms::Model::Lab => {
|
||||
let cms::Lab { l, a, b } = self.to_lab();
|
||||
Color::Lab { l, a, b }
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and
|
||||
/// Gray inks scale by `t`, RGB and Lab mix toward white (`t` = 0 is paper white, 1 the colour
|
||||
/// itself; Lab toward L 100, a = b = 0, as a PDF Lab alternate interpolates).
|
||||
pub fn tinted(self, t: f32) -> Color {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 1.0 {
|
||||
return self;
|
||||
}
|
||||
match self {
|
||||
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: c * t, m: m * t, y: y * t, k: k * t },
|
||||
Color::Gray { k } => Color::Gray { k: k * t },
|
||||
Color::Rgb { r, g, b } => {
|
||||
let w = |v: f32| v + (1.0 - v) * (1.0 - t);
|
||||
Color::Rgb { r: w(r), g: w(g), b: w(b) }
|
||||
}
|
||||
Color::Lab { l, a, b } => Color::Lab { l: 100.0 - (100.0 - l) * t, a: a * t, b: b * t },
|
||||
}
|
||||
}
|
||||
pub fn model_name(&self) -> &'static str {
|
||||
match self {
|
||||
Color::Rgb { .. } => "RGB",
|
||||
Color::Cmyk { .. } => "CMYK",
|
||||
Color::Gray { .. } => "Grayscale",
|
||||
Color::Lab { .. } => "Lab",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations
|
||||
/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from.
|
||||
pub fn keep_model(orig: Color, new: Color) -> Color {
|
||||
new.in_model(orig.model())
|
||||
}
|
||||
|
||||
/// What fills or strokes an object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
@@ -189,10 +313,15 @@ pub enum Paint {
|
||||
#[default]
|
||||
None,
|
||||
Solid {
|
||||
/// The colour shown: with a link, the swatch's colour at `tint` ([`Color::tinted`]).
|
||||
color: Color,
|
||||
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
swatch: Option<String>,
|
||||
/// Tint of the linked swatch (0..1; 1 without a link): a spot tint prints this much of its
|
||||
/// plate, and swatch edits keep it.
|
||||
#[serde(default = "full_tint", skip_serializing_if = "is_full_tint")]
|
||||
tint: f32,
|
||||
},
|
||||
Gradient(Box<GradientPaint>),
|
||||
Pattern {
|
||||
@@ -208,9 +337,22 @@ fn is_identity(a: &kurbo::Affine) -> bool {
|
||||
*a == kurbo::Affine::IDENTITY
|
||||
}
|
||||
|
||||
pub(crate) fn full_tint() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
pub(crate) fn is_full_tint(t: &f32) -> bool {
|
||||
*t == 1.0
|
||||
}
|
||||
|
||||
/// A tint as a whole percentage ("40" for 0.4).
|
||||
pub fn tint_percent(t: f32) -> f32 {
|
||||
(t.clamp(0.0, 1.0) * 100.0).round()
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn solid(c: Color) -> Self {
|
||||
Paint::Solid { color: c, swatch: None }
|
||||
Paint::Solid { color: c, swatch: None, tint: 1.0 }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
matches!(self, Paint::None)
|
||||
@@ -221,15 +363,41 @@ impl Paint {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
|
||||
pub fn registration() -> Self {
|
||||
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
|
||||
}
|
||||
/// Is this a colour linked to the Registration swatch (it prints on every plate)?
|
||||
pub fn is_registration(&self) -> bool {
|
||||
matches!(self, Paint::Solid { swatch: Some(s), .. } if s == swatch::REGISTRATION)
|
||||
}
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Paint::None => "None".into(),
|
||||
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, swatch: Some(n), tint } if *tint < 1.0 => format!("{n} {}% ({})", tint_percent(*tint), color.to_hex()),
|
||||
Paint::Solid { color, swatch: Some(n), .. } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, .. } => color.to_hex(),
|
||||
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
|
||||
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
|
||||
}
|
||||
}
|
||||
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
|
||||
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
|
||||
/// left without a link gets tint 1. Returns whether anything changed.
|
||||
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
|
||||
let changed = f(c, link, tint);
|
||||
if link.is_none() {
|
||||
*tint = 1.0;
|
||||
}
|
||||
changed
|
||||
};
|
||||
match self {
|
||||
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -277,6 +445,99 @@ mod tests {
|
||||
assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complement_keeps_the_model() {
|
||||
assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement_keep_model(), Color::rgb(0.0, 1.0, 1.0));
|
||||
// Same as turning the hue by 180° for RGB.
|
||||
let c = Color::rgb8(204, 102, 51);
|
||||
assert_eq!(c.complement_keep_model().to_hex(), c.complement().to_hex());
|
||||
assert_eq!(Color::cmyk(0.0, 1.0, 1.0, 0.2).complement_keep_model(), Color::cmyk(1.0, 0.0, 0.0, 0.2));
|
||||
assert_eq!(Color::gray(0.3).complement_keep_model(), Color::gray(0.3));
|
||||
assert_eq!(Color::gray(0.25).invert_keep_model(), Color::gray(0.75));
|
||||
assert!(matches!(Color::cmyk(0.0, 1.0, 1.0, 0.0).invert_keep_model(), Color::Cmyk { .. }));
|
||||
assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_convert_between_models_and_keep_them() {
|
||||
use cms::Model;
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0));
|
||||
assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged");
|
||||
assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25));
|
||||
assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0));
|
||||
assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk);
|
||||
assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray);
|
||||
assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_scale_inks_and_mix_rgb_toward_white() {
|
||||
let close = |a: Color, b: Color| {
|
||||
let v = |c: Color| match c {
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Gray { k } => vec![k],
|
||||
Color::Lab { l, a, b } => vec![l, a, b],
|
||||
};
|
||||
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
};
|
||||
assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08)));
|
||||
assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25)));
|
||||
assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white");
|
||||
assert!(close(Color::lab(40.0, 60.0, -20.0).tinted(0.25), Color::lab(85.0, 15.0, -5.0)), "Lab mixes toward paper white");
|
||||
assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6));
|
||||
assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0));
|
||||
assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped");
|
||||
assert_eq!(tint_percent(0.404), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_serialize_only_below_full_and_read_old_files() {
|
||||
let full = serde_json::to_string(&Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }).unwrap();
|
||||
assert!(!full.contains("tint"), "{full}");
|
||||
let old: Paint = serde_json::from_str(r#"{"type":"solid","color":{"model":"gray","k":1.0},"swatch":"Ink"}"#).unwrap();
|
||||
assert_eq!(old, Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 });
|
||||
let t = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"tint\":0.4"), "{s}");
|
||||
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), t);
|
||||
assert_eq!(t.label(), "Ink 40% (#999999)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_links_resets_the_tint_of_unlinked_colours() {
|
||||
let mut p = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
|
||||
assert!(p.map_links(&mut |_, l, _| l.take().is_some()));
|
||||
assert_eq!(p, Paint::solid(Color::gray(0.4)));
|
||||
let mut g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
g.map_links(&mut |_, l, t| {
|
||||
*l = Some("Ink".into());
|
||||
*t = 0.5;
|
||||
true
|
||||
});
|
||||
let Paint::Gradient(g) = g else { panic!("a gradient") };
|
||||
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_serialize_with_their_tag_and_keep_their_model() {
|
||||
let c = Color::lab(52.5, 70.0, -30.0);
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(s, r#"{"model":"lab","l":52.5,"a":70.0,"b":-30.0}"#);
|
||||
assert_eq!(serde_json::from_str::<Color>(&s).unwrap(), c);
|
||||
assert_eq!((c.model(), c.model_name()), (cms::Model::Lab, "Lab"));
|
||||
assert_eq!(c.complement_keep_model(), Color::lab(52.5, -70.0, 30.0));
|
||||
assert_eq!(c.invert_keep_model().model(), cms::Model::Lab);
|
||||
// Results of colour operations on a Lab colour stay Lab, and the conversion is exact.
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let Color::Lab { l, a, b } = keep_model(c, red) else { panic!("Lab") };
|
||||
assert!((l - 54.29).abs() < 0.1 && (a - 80.8).abs() < 0.3 && (b - 69.9).abs() < 0.3, "{l} {a} {b}");
|
||||
assert_eq!(keep_model(c, red).to_hex(), "#ff0000");
|
||||
assert_eq!(Color::lab(100.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
assert_eq!(Color::lab(0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde() {
|
||||
let p = Paint::solid(Color::rgb8(10, 20, 30));
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Swatch libraries: read-only sets of swatches the library panel opens and adds from. Every
|
||||
//! built-in library is computed here (names and palettes are our own; nothing is copied from any
|
||||
//! other application or colour system), so the colours are exact and the same on every run.
|
||||
//!
|
||||
//! Colours are designed in OKLCH (perceptual lightness, chroma and hue), mapped into sRGB by
|
||||
//! lowering chroma until they fit, and rounded to 8-bit RGB.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup};
|
||||
|
||||
/// A library: its name, its ungrouped swatches and its colour groups.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SwatchLibrary {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub swatches: Vec<Swatch>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<SwatchGroup>,
|
||||
}
|
||||
|
||||
impl SwatchLibrary {
|
||||
/// Every swatch: the ungrouped ones first, then each group's in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Swatch> {
|
||||
self.swatches.iter().chain(self.groups.iter().flat_map(|g| g.swatches.iter()))
|
||||
}
|
||||
/// The number of swatches.
|
||||
pub fn len(&self) -> usize {
|
||||
self.swatches.len() + self.groups.iter().map(|g| g.swatches.len()).sum::<usize>()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
|
||||
self.iter().find(|s| s.name == name)
|
||||
}
|
||||
pub fn group(&self, name: &str) -> Option<&SwatchGroup> {
|
||||
self.groups.iter().find(|g| g.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in library: a stable id, its menu name and the function that computes it.
|
||||
pub struct BuiltinLibrary {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
make: fn() -> (Vec<Swatch>, Vec<SwatchGroup>),
|
||||
}
|
||||
|
||||
macro_rules! lib {
|
||||
($id:literal, $name:literal, $make:expr) => {
|
||||
BuiltinLibrary { id: $id, name: $name, make: $make }
|
||||
};
|
||||
}
|
||||
|
||||
/// The built-in swatch libraries, in menu order.
|
||||
pub const SWATCH_LIBRARIES: &[BuiltinLibrary] = &[
|
||||
lib!("web-safe-216", "Web Safe 216", web_safe),
|
||||
lib!("grays-neutrals", "Grays and Neutrals", grays_neutrals),
|
||||
lib!("earth-tones", "Earth Tones", earth_tones),
|
||||
lib!("skin-tone-ramps", "Skin Tone Ramps", skin_tones),
|
||||
lib!("pastels", "Pastels", pastels),
|
||||
lib!("brights", "Brights", brights),
|
||||
lib!("metallic-gradients", "Metallic Gradients", metallic),
|
||||
lib!("perceptual-scales", "Perceptual Scales", perceptual),
|
||||
lib!("harmony-sets", "Harmony Sets", harmony_sets),
|
||||
];
|
||||
|
||||
/// The built-in gradient libraries (Window → Swatch Libraries → Gradients), in menu order.
|
||||
pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
|
||||
lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients),
|
||||
lib!("sky-gradients", "Sky Gradients", sky_gradients),
|
||||
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
|
||||
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
|
||||
lib!("radial-glows", "Radial Glows", radial_glows),
|
||||
];
|
||||
|
||||
/// Every built-in library: the swatch libraries, then the gradient libraries.
|
||||
fn builtins() -> impl Iterator<Item = &'static BuiltinLibrary> {
|
||||
SWATCH_LIBRARIES.iter().chain(GRADIENT_LIBRARIES)
|
||||
}
|
||||
|
||||
/// Built-in library `id` (swatch or gradient library), computed once.
|
||||
pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
|
||||
static LIBS: OnceLock<Vec<Arc<SwatchLibrary>>> = OnceLock::new();
|
||||
let libs = LIBS.get_or_init(|| {
|
||||
builtins()
|
||||
.map(|b| {
|
||||
let (swatches, groups) = (b.make)();
|
||||
Arc::new(SwatchLibrary { name: b.name.into(), swatches, groups })
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
builtins().position(|b| b.id == id).map(|i| libs[i].clone())
|
||||
}
|
||||
|
||||
// ---------- colour construction ----------
|
||||
|
||||
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
|
||||
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
|
||||
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
|
||||
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
let lin = [
|
||||
4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3,
|
||||
-1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3,
|
||||
-0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3,
|
||||
];
|
||||
lin.map(|v| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 })
|
||||
}
|
||||
|
||||
/// OKLab `[l, a, b]` of a colour's display RGB.
|
||||
pub fn to_oklab(c: &Color) -> [f64; 3] {
|
||||
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| {
|
||||
let v = f64::from(v);
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
});
|
||||
let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
|
||||
let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
|
||||
let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
|
||||
[
|
||||
0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
|
||||
1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
|
||||
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
|
||||
]
|
||||
}
|
||||
|
||||
/// The 8-bit sRGB colour of OKLCH `(l, c, h°)`, with the chroma lowered until it fits in sRGB.
|
||||
fn oklch(l: f64, c: f64, h: f64) -> Color {
|
||||
let (sin, cos) = h.to_radians().sin_cos();
|
||||
let at = |c: f64| oklab_to_srgb(l, c * cos, c * sin);
|
||||
let fits = |rgb: [f64; 3]| rgb.iter().all(|v| (-1e-4..=1.0001).contains(v));
|
||||
let rgb = if fits(at(c)) {
|
||||
at(c)
|
||||
} else {
|
||||
// Bisect the largest chroma in gamut.
|
||||
let (mut lo, mut hi) = (0.0, c);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if fits(at(mid)) { lo = mid } else { hi = mid }
|
||||
}
|
||||
at(lo)
|
||||
};
|
||||
let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color::rgb8(q(rgb[0]), q(rgb[1]), q(rgb[2]))
|
||||
}
|
||||
|
||||
/// OKLab interpolation of `a` → `b` at `t`, as an 8-bit sRGB colour.
|
||||
fn mix(a: [f64; 3], b: [f64; 3], t: f64) -> Color {
|
||||
let p = [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
|
||||
let (c, h) = (p[1].hypot(p[2]), p[2].atan2(p[1]).to_degrees());
|
||||
oklch(p[0], c, h)
|
||||
}
|
||||
|
||||
/// OKLab `[l, a, b]` of OKLCH `(l, c, h°)`.
|
||||
fn lab(l: f64, c: f64, h: f64) -> [f64; 3] {
|
||||
let (sin, cos) = h.to_radians().sin_cos();
|
||||
[l, c * cos, c * sin]
|
||||
}
|
||||
|
||||
fn solid(name: impl Into<String>, color: Color) -> Swatch {
|
||||
Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }
|
||||
}
|
||||
|
||||
fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
|
||||
SwatchGroup { name: name.into(), swatches }
|
||||
}
|
||||
|
||||
/// A gradient swatch through `stops` of (offset, colour, opacity).
|
||||
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
|
||||
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
|
||||
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
|
||||
}
|
||||
|
||||
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
|
||||
/// named "`name` 1"…
|
||||
fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup {
|
||||
let swatches = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / (n - 1).max(1) as f64;
|
||||
solid(format!("{name} {}", i + 1), oklch(l0 + (l1 - l0) * t, c(t), h(t)))
|
||||
})
|
||||
.collect();
|
||||
group(name, swatches)
|
||||
}
|
||||
|
||||
/// Twelve named OKLCH hues around the wheel.
|
||||
const HUES: [(&str, f64); 12] = [
|
||||
("Red", 25.0),
|
||||
("Orange", 55.0),
|
||||
("Amber", 75.0),
|
||||
("Yellow", 100.0),
|
||||
("Lime", 125.0),
|
||||
("Green", 145.0),
|
||||
("Teal", 180.0),
|
||||
("Cyan", 205.0),
|
||||
("Azure", 235.0),
|
||||
("Blue", 265.0),
|
||||
("Violet", 295.0),
|
||||
("Magenta", 330.0),
|
||||
];
|
||||
|
||||
/// One group of the twelve hues at OKLCH lightness `l` and chroma `c`, named "`prefix` Red"…
|
||||
fn hue_row(prefix: &str, l: f64, c: f64) -> SwatchGroup {
|
||||
group(prefix, HUES.iter().map(|(n, h)| solid(format!("{prefix} {n}"), oklch(l, c, *h))).collect())
|
||||
}
|
||||
|
||||
// ---------- the libraries ----------
|
||||
|
||||
/// The 216 colours whose channels are multiples of 0x33, named by their hex value.
|
||||
fn web_safe() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let mut s = Vec::with_capacity(216);
|
||||
for r in 0..6u8 {
|
||||
for g in 0..6u8 {
|
||||
for b in 0..6u8 {
|
||||
let c = Color::rgb8(r * 0x33, g * 0x33, b * 0x33);
|
||||
s.push(solid(c.to_hex().to_uppercase(), c));
|
||||
}
|
||||
}
|
||||
}
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
fn grays_neutrals() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let grays = (0..=20).map(|i| solid(format!("K={}", i * 5), Color::gray(i as f32 / 20.0))).collect();
|
||||
let tinted = |name: &str, c: f64, h: f64| ramp(name, 9, (0.95, 0.25), |_| c, |_| h);
|
||||
let neutrals = [
|
||||
("Ivory", 0.96, 0.02, 95.0),
|
||||
("Linen", 0.92, 0.02, 70.0),
|
||||
("Oat", 0.86, 0.035, 80.0),
|
||||
("Greige", 0.72, 0.02, 75.0),
|
||||
("Stone", 0.66, 0.015, 95.0),
|
||||
("Taupe", 0.56, 0.03, 50.0),
|
||||
("Slate", 0.48, 0.025, 250.0),
|
||||
("Charcoal", 0.30, 0.01, 260.0),
|
||||
];
|
||||
let neutrals = neutrals.iter().map(|&(n, l, c, h)| solid(n, oklch(l, c, h))).collect();
|
||||
let groups = vec![group("Grays", grays), tinted("Warm Gray", 0.012, 70.0), tinted("Cool Gray", 0.015, 250.0), group("Neutrals", neutrals)];
|
||||
(vec![], groups)
|
||||
}
|
||||
|
||||
fn earth_tones() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let families = [
|
||||
("Clay", 0.10, 40.0),
|
||||
("Terracotta", 0.13, 35.0),
|
||||
("Ochre", 0.11, 80.0),
|
||||
("Umber", 0.06, 60.0),
|
||||
("Sand", 0.05, 85.0),
|
||||
("Olive", 0.08, 115.0),
|
||||
("Moss", 0.07, 130.0),
|
||||
];
|
||||
// Chroma peaks mid-ramp, as earth pigments do.
|
||||
let groups = families.iter().map(|&(n, c, h)| ramp(n, 6, (0.86, 0.34), |t| c * (0.6 + 0.4 * (1.0 - (2.0 * t - 1.0).powi(2))), |_| h)).collect();
|
||||
(vec![], groups)
|
||||
}
|
||||
|
||||
fn skin_tones() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let ramps = [("Rosy", 0.075, 35.0), ("Neutral", 0.07, 55.0), ("Golden", 0.08, 70.0), ("Olive", 0.06, 85.0)];
|
||||
// Light tones are paler; deep tones keep warmth but lose a little chroma.
|
||||
let groups = ramps.iter().map(|&(n, c, h)| ramp(n, 8, (0.93, 0.32), |t| c * (0.45 + 0.75 * t - 0.35 * t * t), |t| h - 8.0 * t)).collect();
|
||||
(vec![], groups)
|
||||
}
|
||||
|
||||
fn pastels() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
(vec![], vec![hue_row("Pastel", 0.91, 0.055), hue_row("Soft", 0.84, 0.085)])
|
||||
}
|
||||
|
||||
fn brights() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
// Chroma beyond sRGB: each hue lands on the gamut edge at that lightness.
|
||||
(vec![], vec![hue_row("Vivid", 0.72, 0.4), hue_row("Deep", 0.52, 0.4)])
|
||||
}
|
||||
|
||||
fn metallic() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let metals: [(&str, f64, f64, f64); 10] = [
|
||||
("Gold", 0.78, 0.12, 85.0),
|
||||
("Silver", 0.80, 0.005, 250.0),
|
||||
("Bronze", 0.62, 0.09, 65.0),
|
||||
("Copper", 0.64, 0.12, 45.0),
|
||||
("Brass", 0.76, 0.10, 95.0),
|
||||
("Chrome", 0.72, 0.012, 230.0),
|
||||
("Rose Gold", 0.76, 0.07, 30.0),
|
||||
("Gunmetal", 0.45, 0.015, 250.0),
|
||||
("Platinum", 0.86, 0.008, 90.0),
|
||||
("Pewter", 0.60, 0.01, 200.0),
|
||||
];
|
||||
// Light, base, shadow, highlight, base: the banding of polished metal.
|
||||
let bands: [(f32, f64); 5] = [(0.0, 0.16), (0.3, 0.0), (0.5, -0.2), (0.72, 0.12), (1.0, -0.06)];
|
||||
let s = metals
|
||||
.iter()
|
||||
.map(|&(n, l, c, h)| {
|
||||
let stops: Vec<(f32, Color, f32)> = bands.iter().map(|&(at, dl)| (at, oklch((l + dl).clamp(0.05, 0.98), c, h), 1.0)).collect();
|
||||
gradient(n, GradientKind::Linear, &stops)
|
||||
})
|
||||
.collect();
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// OKLCH `(l, c, h°)` anchors a scale passes through.
|
||||
type Anchors = &'static [(f64, f64, f64)];
|
||||
|
||||
/// A linear gradient through evenly spaced, opaque `colors`.
|
||||
fn even(name: &str, kind: GradientKind, colors: &[Color]) -> Swatch {
|
||||
let last = (colors.len() - 1).max(1) as f32;
|
||||
let stops: Vec<(f32, Color, f32)> = colors.iter().enumerate().map(|(i, c)| (i as f32 / last, *c, 1.0)).collect();
|
||||
gradient(name, kind, &stops)
|
||||
}
|
||||
|
||||
/// OKLCH `(l, c, h°)` colours.
|
||||
fn lch(points: &[(f64, f64, f64)]) -> Vec<Color> {
|
||||
points.iter().map(|&(l, c, h)| oklch(l, c, h)).collect()
|
||||
}
|
||||
|
||||
/// Full-hue sweeps and hue-neighbour blends.
|
||||
fn spectrum_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let wheel: Vec<Color> = (0..=6).map(|i| oklch(0.72, 0.4, 25.0 + i as f64 * 60.0)).collect();
|
||||
let mut s = vec![
|
||||
even("Spectrum", GradientKind::Linear, &wheel),
|
||||
even("Soft Spectrum", GradientKind::Linear, &lch(&[(0.88, 0.08, 25.0), (0.9, 0.08, 145.0), (0.86, 0.08, 265.0), (0.88, 0.08, 25.0)])),
|
||||
];
|
||||
// Each hue to the one two steps round the wheel.
|
||||
s.extend((0..HUES.len()).map(|i| {
|
||||
let ((a, ha), (b, hb)) = (HUES[i], HUES[(i + 2) % HUES.len()]);
|
||||
even(&format!("{a} to {b}"), GradientKind::Linear, &[oklch(0.7, 0.4, ha), oklch(0.7, 0.4, hb)])
|
||||
}));
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
fn sky_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let skies: [(&str, Anchors); 6] = [
|
||||
("Dawn", &[(0.45, 0.08, 280.0), (0.72, 0.12, 350.0), (0.9, 0.09, 75.0)]),
|
||||
("Noon", &[(0.55, 0.13, 250.0), (0.78, 0.09, 235.0), (0.95, 0.03, 220.0)]),
|
||||
("Sunset", &[(0.35, 0.09, 300.0), (0.62, 0.18, 20.0), (0.85, 0.15, 70.0)]),
|
||||
("Dusk", &[(0.25, 0.07, 275.0), (0.48, 0.1, 310.0), (0.7, 0.1, 30.0)]),
|
||||
("Night", &[(0.15, 0.04, 270.0), (0.3, 0.07, 265.0)]),
|
||||
("Overcast", &[(0.62, 0.015, 240.0), (0.86, 0.01, 230.0)]),
|
||||
];
|
||||
(skies.iter().map(|(n, p)| even(n, GradientKind::Linear, &lch(p))).collect(), vec![])
|
||||
}
|
||||
|
||||
fn neutral_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let (white, black) = (Color::gray(0.0), Color::gray(1.0));
|
||||
let s = vec![
|
||||
even("White to Black", GradientKind::Linear, &[white, black]),
|
||||
even("Light Grays", GradientKind::Linear, &[white, Color::gray(0.4)]),
|
||||
even("Dark Grays", GradientKind::Linear, &[Color::gray(0.6), black]),
|
||||
even("Warm Grays", GradientKind::Linear, &lch(&[(0.95, 0.012, 70.0), (0.3, 0.012, 70.0)])),
|
||||
even("Cool Grays", GradientKind::Linear, &lch(&[(0.95, 0.015, 250.0), (0.3, 0.015, 250.0)])),
|
||||
gradient("Fade to Transparent", GradientKind::Linear, &[(0.0, black, 1.0), (1.0, black, 0.0)]),
|
||||
gradient("White Fade", GradientKind::Linear, &[(0.0, white, 1.0), (1.0, white, 0.0)]),
|
||||
gradient("Vignette", GradientKind::Radial, &[(0.0, black, 0.0), (0.6, black, 0.0), (1.0, black, 0.7)]),
|
||||
];
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// Pairs of hues from opposite sides of the wheel, at different lightness.
|
||||
fn duotone_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let pairs = [
|
||||
("Coral", 0.7, 30.0, "Navy", 0.32, 265.0),
|
||||
("Plum", 0.38, 330.0, "Lime", 0.88, 125.0),
|
||||
("Teal", 0.5, 190.0, "Gold", 0.85, 90.0),
|
||||
("Indigo", 0.3, 280.0, "Peach", 0.86, 55.0),
|
||||
("Forest", 0.35, 150.0, "Rose", 0.8, 0.0),
|
||||
];
|
||||
let s = pairs
|
||||
.iter()
|
||||
.map(|&(a, la, ha, b, lb, hb)| even(&format!("{a} and {b}"), GradientKind::Linear, &[oklch(la, 0.15, ha), oklch(lb, 0.15, hb)]))
|
||||
.collect();
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// A bright centre fading to a transparent edge, per hue.
|
||||
fn radial_glows() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let s = HUES
|
||||
.iter()
|
||||
.map(|(n, h)| {
|
||||
let c = oklch(0.78, 0.2, *h);
|
||||
gradient(&format!("{n} Glow"), GradientKind::Radial, &[(0.0, Color::WHITE, 1.0), (0.25, c, 1.0), (1.0, c, 0.0)])
|
||||
})
|
||||
.collect();
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
|
||||
const SEQUENTIAL: [(&str, Anchors); 5] = [
|
||||
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
|
||||
("Lagoon", &[(0.25, 0.07, 265.0), (0.60, 0.12, 200.0), (0.93, 0.12, 130.0)]),
|
||||
("Dusk", &[(0.20, 0.08, 280.0), (0.60, 0.15, 330.0), (0.92, 0.07, 60.0)]),
|
||||
("Moss", &[(0.25, 0.05, 150.0), (0.92, 0.07, 110.0)]),
|
||||
("Ink", &[(0.20, 0.02, 260.0), (0.96, 0.01, 260.0)]),
|
||||
];
|
||||
|
||||
/// `n` colours along OKLab segments between `anchors`.
|
||||
fn scale(name: &str, n: usize, anchors: &[(f64, f64, f64)]) -> SwatchGroup {
|
||||
let pts: Vec<[f64; 3]> = anchors.iter().map(|&(l, c, h)| lab(l, c, h)).collect();
|
||||
let segs = (pts.len() - 1) as f64;
|
||||
let swatches = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / (n - 1) as f64 * segs;
|
||||
let k = (t.floor() as usize).min(pts.len() - 2);
|
||||
solid(format!("{name} {}", i + 1), mix(pts[k], pts[k + 1], t - k as f64))
|
||||
})
|
||||
.collect();
|
||||
group(name, swatches)
|
||||
}
|
||||
|
||||
/// Scales whose lightness rises evenly (sequential) plus two diverging ones.
|
||||
fn perceptual() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let mut groups: Vec<SwatchGroup> = SEQUENTIAL.iter().map(|(n, a)| scale(n, 9, a)).collect();
|
||||
groups.push(scale("Clay to Teal", 9, &[(0.45, 0.12, 40.0), (0.96, 0.01, 80.0), (0.45, 0.09, 200.0)]));
|
||||
groups.push(scale("Plum to Olive", 9, &[(0.40, 0.11, 330.0), (0.96, 0.01, 90.0), (0.45, 0.09, 120.0)]));
|
||||
(vec![], groups)
|
||||
}
|
||||
|
||||
/// Colour-wheel harmonies of a few base colours (the Color Guide's rules).
|
||||
fn harmony_sets() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
use crate::harmony::Harmony;
|
||||
let bases = [("Coral", Color::from_hsb(8.0, 0.68, 0.94)), ("Teal", Color::from_hsb(176.0, 0.72, 0.62))];
|
||||
let rules = [Harmony::Complementary, Harmony::SplitComplementary, Harmony::Analogous, Harmony::Triad, Harmony::Tetrad, Harmony::Pentagram];
|
||||
let mut groups = vec![];
|
||||
for (bn, base) in bases {
|
||||
for rule in rules {
|
||||
let name = format!("{} {bn}", rule.label());
|
||||
// Rounded to 8 bits like the other libraries.
|
||||
let swatches = rule
|
||||
.apply(base)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
solid(format!("{name} {}", i + 1), Color::rgb8(r, g, b))
|
||||
})
|
||||
.collect();
|
||||
groups.push(SwatchGroup { name, swatches });
|
||||
}
|
||||
}
|
||||
(vec![], groups)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all() -> Vec<Arc<SwatchLibrary>> {
|
||||
builtins().map(|b| builtin_library(b.id).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Every component of every colour (solid or gradient stop).
|
||||
fn components(lib: &SwatchLibrary) -> Vec<f32> {
|
||||
let comps = |c: &Color| match *c {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Gray { k } => vec![k],
|
||||
// Normalized to 0..1 like the others.
|
||||
Color::Lab { l, a, b } => vec![l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0],
|
||||
};
|
||||
lib.iter()
|
||||
.flat_map(|s| match &s.paint {
|
||||
Paint::Solid { color, .. } => comps(color),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().flat_map(|st| comps(&st.color)).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_are_deterministic_unique_and_in_range() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for (b, lib) in builtins().zip(all()) {
|
||||
assert!(ids.insert(b.id), "duplicate id {}", b.id);
|
||||
assert_eq!(lib.name, b.name);
|
||||
assert!(lib.len() >= 5, "{} has {}", b.id, lib.len());
|
||||
// Recomputing gives the same library.
|
||||
let (s, g) = (b.make)();
|
||||
assert_eq!((s, g), (lib.swatches.clone(), lib.groups.clone()), "{} is deterministic", b.id);
|
||||
// Swatch and group names share one namespace, as in a document.
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for n in lib.iter().map(|s| &s.name).chain(lib.groups.iter().map(|g| &g.name)) {
|
||||
assert!(names.insert(n.clone()), "{}: duplicate name {n}", b.id);
|
||||
}
|
||||
assert!(components(&lib).iter().all(|v| (0.0..=1.0).contains(v)), "{} out of range", b.id);
|
||||
assert!(lib.iter().all(|s| !s.paint.is_none() && !s.global && !s.spot));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_safe_has_the_216_web_colours() {
|
||||
let lib = builtin_library("web-safe-216").unwrap();
|
||||
assert_eq!(lib.len(), 216);
|
||||
let mut hex: Vec<String> = lib.iter().map(|s| s.paint.color().unwrap().to_hex()).collect();
|
||||
hex.dedup();
|
||||
assert_eq!(hex.len(), 216);
|
||||
assert!(lib.iter().all(|s| s.paint.color().unwrap().to_rgba8(1.0)[..3].iter().all(|v| v % 0x33 == 0)));
|
||||
assert_eq!(lib.swatches[0].name, "#000000");
|
||||
assert_eq!(lib.swatches[215].name, "#FFFFFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_scales_rise_in_lightness() {
|
||||
let lib = builtin_library("perceptual-scales").unwrap();
|
||||
for (name, _) in SEQUENTIAL {
|
||||
let l: Vec<f64> = lib.group(name).unwrap().swatches.iter().map(|s| to_oklab(&s.paint.color().unwrap())[0]).collect();
|
||||
assert!(l.windows(2).all(|w| w[1] > w[0] + 0.03), "{name}: {l:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oklch_round_trips_in_gamut_colours() {
|
||||
let c = oklch(0.7, 0.1, 150.0);
|
||||
let [l, a, b] = to_oklab(&c);
|
||||
assert!((l - 0.7).abs() < 0.01 && (a.hypot(b) - 0.1).abs() < 0.01, "{l} {a} {b}");
|
||||
// Out-of-gamut chroma is clipped to the gamut edge, keeping lightness.
|
||||
let v = oklch(0.72, 0.4, 25.0);
|
||||
assert!((to_oklab(&v)[0] - 0.72).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_libraries_have_sorted_stops() {
|
||||
for b in GRADIENT_LIBRARIES {
|
||||
let lib = builtin_library(b.id).unwrap();
|
||||
assert!(lib.iter().all(|s| matches!(s.paint, Paint::Gradient(_))), "{} holds gradients only", b.id);
|
||||
}
|
||||
for lib in all() {
|
||||
for s in lib.iter() {
|
||||
if let Paint::Gradient(g) = &s.paint {
|
||||
let st = &g.gradient.stops;
|
||||
assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name);
|
||||
assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
|
||||
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
|
||||
//! headers) and CSS custom properties (written only).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Color, GradientKind, Paint, Swatch, SwatchGroup, SwatchLibrary};
|
||||
|
||||
/// A swatch library file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PaletteFormat {
|
||||
/// `.vcswatches`: JSON, lossless.
|
||||
Native,
|
||||
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
|
||||
Gpl,
|
||||
/// `.css`: custom properties on `:root` (colours and gradients; written only).
|
||||
Css,
|
||||
}
|
||||
|
||||
impl PaletteFormat {
|
||||
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
|
||||
|
||||
/// The format's id, which is also its extension.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PaletteFormat::Native => "vcswatches",
|
||||
PaletteFormat::Gpl => "gpl",
|
||||
PaletteFormat::Css => "css",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
|
||||
PaletteFormat::Gpl => "GPL Palette (.gpl)",
|
||||
PaletteFormat::Css => "CSS Custom Properties (.css)",
|
||||
}
|
||||
}
|
||||
/// The format with id or extension `s` (any case, leading dot allowed).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim_start_matches('.').to_ascii_lowercase();
|
||||
Self::ALL.into_iter().find(|f| f.id() == s)
|
||||
}
|
||||
/// Can libraries be read back from this format?
|
||||
pub fn readable(self) -> bool {
|
||||
self != PaletteFormat::Css
|
||||
}
|
||||
}
|
||||
|
||||
/// The native file: a header around the library.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct NativeFile {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(flatten)]
|
||||
library: SwatchLibrary,
|
||||
}
|
||||
|
||||
const NATIVE_FORMAT: &str = "vcswatches";
|
||||
const GPL_HEADER: &str = "GIMP Palette";
|
||||
const GPL_GROUP: &str = "# Group:";
|
||||
|
||||
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
|
||||
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
|
||||
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
|
||||
match format {
|
||||
PaletteFormat::Native => {
|
||||
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
|
||||
let library = SwatchLibrary {
|
||||
name: lib.name.clone(),
|
||||
swatches: strip(&lib.swatches),
|
||||
groups: lib.groups.iter().map(|g| SwatchGroup { name: g.name.clone(), swatches: strip(&g.swatches) }).collect(),
|
||||
};
|
||||
let file = NativeFile { format: NATIVE_FORMAT.into(), version: 1, library };
|
||||
serde_json::to_string_pretty(&file).unwrap_or_default()
|
||||
}
|
||||
PaletteFormat::Gpl => write_gpl(lib),
|
||||
PaletteFormat::Css => write_css(lib, keep),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of text: no line breaks or tabs.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.split(['\n', '\r', '\t']).collect::<Vec<_>>().join(" ").trim().to_string()
|
||||
}
|
||||
|
||||
fn write_gpl(lib: &SwatchLibrary) -> String {
|
||||
let mut out = format!("{GPL_HEADER}\nName: {}\nColumns: 0\n#\n", one_line(&lib.name));
|
||||
let colors = |out: &mut String, list: &[Swatch]| {
|
||||
for w in list {
|
||||
if let Some(c) = w.paint.color() {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
out.push_str(&format!("{r:3} {g:3} {b:3}\t{}\n", one_line(&w.name)));
|
||||
}
|
||||
}
|
||||
};
|
||||
colors(&mut out, &lib.swatches);
|
||||
for g in &lib.groups {
|
||||
out.push_str(&format!("{GPL_GROUP} {}\n", one_line(&g.name)));
|
||||
colors(&mut out, &g.swatches);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `name` as a CSS custom property (`--` and an escaped identifier): lower case, spaces as
|
||||
/// hyphens, other characters an identifier can't hold escaped with a backslash.
|
||||
pub fn css_property(name: &str) -> String {
|
||||
let mut out = String::from("--");
|
||||
for word in name.split_whitespace() {
|
||||
if out.len() > 2 {
|
||||
out.push('-');
|
||||
}
|
||||
for ch in word.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() {
|
||||
out.push(ch);
|
||||
} else if ch.is_ascii_graphic() {
|
||||
out.push('\\');
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.push_str(&format!("\\{:x} ", ch as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len() == 2 {
|
||||
out.push_str("swatch");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A colour as CSS (`#rrggbb`, or `#rrggbbaa` below full opacity).
|
||||
fn css_color(c: &Color, opacity: f32) -> String {
|
||||
let [r, g, b, a] = c.to_rgba8(opacity);
|
||||
if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }
|
||||
}
|
||||
|
||||
/// A comment's text: `*/` can't end it early.
|
||||
fn css_comment(s: &str) -> String {
|
||||
one_line(s).replace("*/", "* /")
|
||||
}
|
||||
|
||||
fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name));
|
||||
let mut taken: Vec<String> = vec![];
|
||||
let mut props = |out: &mut String, list: &[Swatch]| {
|
||||
for w in list.iter().filter(&keep) {
|
||||
let value = match &w.paint {
|
||||
Paint::Solid { color, .. } => css_color(color, 1.0),
|
||||
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
|
||||
let stops: Vec<String> =
|
||||
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect();
|
||||
match g.gradient.kind {
|
||||
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
|
||||
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
// Names differing only in case or punctuation map to one property: number the others.
|
||||
let base = css_property(&w.name);
|
||||
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base);
|
||||
taken.push(name.clone());
|
||||
let note = if w.spot { " /* spot */" } else { "" };
|
||||
out.push_str(&format!(" {name}: {value};{note}\n"));
|
||||
}
|
||||
};
|
||||
props(&mut out, &lib.swatches);
|
||||
for g in &lib.groups {
|
||||
out.push_str(&format!(" /* {} */\n", css_comment(&g.name)));
|
||||
props(&mut out, &g.swatches);
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
|
||||
/// file doesn't.
|
||||
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
|
||||
let text = text.trim_start_matches('\u{feff}').trim_start();
|
||||
if text.starts_with(GPL_HEADER) {
|
||||
return Ok(read_gpl(text, name));
|
||||
}
|
||||
if text.starts_with('{') {
|
||||
let f: NativeFile = serde_json::from_str(text).map_err(|e| format!("not a swatch library: {e}"))?;
|
||||
if f.format != NATIVE_FORMAT {
|
||||
return Err(format!("not a swatch library (format `{}`)", f.format));
|
||||
}
|
||||
let mut lib = f.library;
|
||||
if lib.name.trim().is_empty() {
|
||||
lib.name = name.into();
|
||||
}
|
||||
return Ok(lib);
|
||||
}
|
||||
Err("not a swatch library (.vcswatches or .gpl)".into())
|
||||
}
|
||||
|
||||
/// Is `text` a library [`read`] understands?
|
||||
pub fn sniff(text: &str) -> bool {
|
||||
let t = text.trim_start_matches('\u{feff}').trim_start();
|
||||
t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT))
|
||||
}
|
||||
|
||||
/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and
|
||||
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are
|
||||
/// named by their values; repeated names get a number.
|
||||
fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
|
||||
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
|
||||
let mut names: Vec<String> = vec![];
|
||||
for line in text.lines().skip(1) {
|
||||
let line = line.trim();
|
||||
if let Some(g) = line.strip_prefix(GPL_GROUP) {
|
||||
lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] });
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = line.strip_prefix("Name:") {
|
||||
lib.name = n.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
// Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped.
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let rgb: Vec<u8> = words.iter().take(3).map_while(|v| v.parse().ok()).collect();
|
||||
let [r, g, b] = rgb[..] else { continue };
|
||||
let rest = words[3..].join(" ");
|
||||
let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest };
|
||||
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base);
|
||||
names.push(name.clone());
|
||||
let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
|
||||
match lib.groups.last_mut() {
|
||||
Some(grp) => grp.swatches.push(w),
|
||||
None => lib.swatches.push(w),
|
||||
}
|
||||
}
|
||||
lib
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Gradient, GradientPaint, GradientStop};
|
||||
|
||||
fn sample() -> SwatchLibrary {
|
||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
|
||||
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
|
||||
],
|
||||
})));
|
||||
SwatchLibrary {
|
||||
name: "Brand */ Colours".into(),
|
||||
swatches: vec![
|
||||
Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true },
|
||||
Swatch { name: "Fade".into(), paint: grad, global: false, spot: false },
|
||||
Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false },
|
||||
],
|
||||
groups: vec![SwatchGroup {
|
||||
name: "Neutrals".into(),
|
||||
swatches: vec![
|
||||
Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false },
|
||||
Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false },
|
||||
],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_round_trip_keeps_cmyk_spot_and_groups() {
|
||||
let lib = sample();
|
||||
let text = write(&lib, PaletteFormat::Native);
|
||||
assert!(sniff(&text));
|
||||
let back = read(&text, "fallback").unwrap();
|
||||
let mut expected = lib.clone();
|
||||
expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. }));
|
||||
assert_eq!(back, expected, "everything but the pattern comes back exactly");
|
||||
assert!(back.swatches[0].spot && back.swatches[0].global);
|
||||
assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
|
||||
assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
|
||||
let text = write(&sample(), PaletteFormat::Gpl);
|
||||
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
|
||||
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
|
||||
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
|
||||
let back = read(&text, "x").unwrap();
|
||||
assert_eq!(back.name, "Brand */ Colours");
|
||||
assert_eq!(back.swatches.len(), 1);
|
||||
assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB");
|
||||
// Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names.
|
||||
let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n";
|
||||
let lib = read(other, "file").unwrap();
|
||||
assert_eq!(lib.name, "Sunset");
|
||||
let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]);
|
||||
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255)));
|
||||
assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_escapes_names_and_writes_gradients() {
|
||||
assert_eq!(css_property("Sky Blue"), "--sky-blue");
|
||||
assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0");
|
||||
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
|
||||
assert_eq!(css_property("Café 50%"), "--café-50\\%");
|
||||
assert_eq!(css_property(" "), "--swatch");
|
||||
let text = write(&sample(), PaletteFormat::Css);
|
||||
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
|
||||
assert!(text.contains(" --ink: #"), "{text}");
|
||||
assert!(text.contains("; /* spot */"));
|
||||
assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}");
|
||||
assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}");
|
||||
assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n"));
|
||||
assert!(!text.contains("tiles"));
|
||||
// Names that collide once escaped are numbered.
|
||||
let lib = SwatchLibrary {
|
||||
name: "x".into(),
|
||||
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
|
||||
groups: vec![],
|
||||
};
|
||||
let text = write(&lib, PaletteFormat::Css);
|
||||
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
//! Recolor Artwork's colour math (our own): colour identity keys, the White/Black/Grays preserve
|
||||
//! rules, the methods that map a row of current colours to its new colour, colour reduction by
|
||||
//! clustering in Lab, the nearest colour of a library and the randomizers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::cms::{Lab, delta_e2000};
|
||||
use crate::{Color, keep_model};
|
||||
|
||||
/// A colour's identity: its model and exact components (to a hundredth of an RGB level, a percent
|
||||
/// or a Lab unit), so colours that only look alike (a CMYK red and an RGB red) stay apart and keep
|
||||
/// their model. Written as `rgb 255 128 0` (0–255), `cmyk 0 100 100 0`, `gray 40` (percent) or
|
||||
/// `lab 55 60 -40` (L 0–100, a and b).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ColorKey {
|
||||
model: u8,
|
||||
v: [i32; 4],
|
||||
}
|
||||
|
||||
/// Key resolution: RGB components are kept to 1/RGB_Q, CMYK and Gray ones to 1/INK_Q and Lab ones
|
||||
/// to 1/LAB_Q (hundredths of the written units).
|
||||
const RGB_Q: f32 = 25_500.0;
|
||||
const INK_Q: f32 = 10_000.0;
|
||||
const LAB_Q: f32 = 100.0;
|
||||
|
||||
impl ColorKey {
|
||||
pub fn of(c: &Color) -> Self {
|
||||
let rgb = |x: f32| (x.clamp(0.0, 1.0) * RGB_Q).round() as i32;
|
||||
let ink = |x: f32| (x.clamp(0.0, 1.0) * INK_Q).round() as i32;
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => Self { model: 0, v: [rgb(r), rgb(g), rgb(b), 0] },
|
||||
Color::Cmyk { c, m, y, k } => Self { model: 1, v: [ink(c), ink(m), ink(y), ink(k)] },
|
||||
Color::Gray { k } => Self { model: 2, v: [ink(k), 0, 0, 0] },
|
||||
Color::Lab { l, a, b } => {
|
||||
let lab = |x: f32| (x * LAB_Q).round() as i32;
|
||||
Self { model: 3, v: [lab(l.clamp(0.0, 100.0)), lab(a), lab(b), 0] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour this key names.
|
||||
pub fn color(&self) -> Color {
|
||||
let f = |i: usize| self.v[i] as f32 / INK_Q;
|
||||
match self.model {
|
||||
0 => Color::rgb(self.v[0] as f32 / RGB_Q, self.v[1] as f32 / RGB_Q, self.v[2] as f32 / RGB_Q),
|
||||
1 => Color::cmyk(f(0), f(1), f(2), f(3)),
|
||||
2 => Color::gray(f(0)),
|
||||
_ => Color::lab(self.v[0] as f32 / LAB_Q, self.v[1] as f32 / LAB_Q, self.v[2] as f32 / LAB_Q),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the written form (see [`ColorKey`]); extra spaces and case are ignored.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let mut it = s.split_whitespace();
|
||||
let model = it.next()?.to_ascii_lowercase();
|
||||
let nums: Vec<f32> = it.map(|t| t.parse::<f32>().ok().filter(|v| v.is_finite())).collect::<Option<_>>()?;
|
||||
let c = match (model.as_str(), nums.as_slice()) {
|
||||
("rgb", [r, g, b]) => Color::rgb(r / 255.0, g / 255.0, b / 255.0),
|
||||
("cmyk", [c, m, y, k]) => Color::cmyk(c / 100.0, m / 100.0, y / 100.0, k / 100.0),
|
||||
("gray", [k]) => Color::gray(k / 100.0),
|
||||
("lab", [l, a, b]) => Color::lab(*l, *a, *b),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self::of(&c))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ColorKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Components are hundredths of the written units: exact with two decimals.
|
||||
let num = |q: i32| {
|
||||
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
};
|
||||
let [a, b, c, k] = self.v.map(num);
|
||||
match self.model {
|
||||
0 => write!(f, "rgb {a} {b} {c}"),
|
||||
1 => write!(f, "cmyk {a} {b} {c} {k}"),
|
||||
2 => write!(f, "gray {a}"),
|
||||
_ => write!(f, "lab {a} {b} {c}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The neutral a colour is, if any: paper white, black (rich blacks included) or a grey.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Neutral {
|
||||
White,
|
||||
Black,
|
||||
Gray,
|
||||
}
|
||||
|
||||
impl Neutral {
|
||||
pub fn of(c: &Color) -> Option<Self> {
|
||||
const E: f32 = 0.005;
|
||||
match *c {
|
||||
Color::Gray { k } if k <= E => Some(Neutral::White),
|
||||
Color::Gray { k } if k >= 1.0 - E => Some(Neutral::Black),
|
||||
Color::Gray { .. } => Some(Neutral::Gray),
|
||||
Color::Cmyk { c, m, y, k } if c.max(m).max(y).max(k) <= E => Some(Neutral::White),
|
||||
Color::Cmyk { k, .. } if k >= 1.0 - E => Some(Neutral::Black),
|
||||
Color::Cmyk { c, m, y, .. } => (c.max(m).max(y) <= E).then_some(Neutral::Gray),
|
||||
// Lab: lightness at the ends, no chroma between them (to half a unit).
|
||||
Color::Lab { l, a, b } if l >= 99.5 && a.abs().max(b.abs()) <= 0.5 => Some(Neutral::White),
|
||||
Color::Lab { l, .. } if l <= 0.5 => Some(Neutral::Black),
|
||||
Color::Lab { a, b, .. } => (a.abs().max(b.abs()) <= 0.5).then_some(Neutral::Gray),
|
||||
Color::Rgb { r, g, b } => {
|
||||
let (lo, hi) = (r.min(g).min(b), r.max(g).max(b));
|
||||
if lo >= 1.0 - E {
|
||||
Some(Neutral::White)
|
||||
} else if hi <= E {
|
||||
Some(Neutral::Black)
|
||||
} else {
|
||||
(hi - lo <= E).then_some(Neutral::Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which neutrals Recolor Artwork leaves alone (White and Black by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Preserve {
|
||||
pub white: bool,
|
||||
pub black: bool,
|
||||
pub grays: bool,
|
||||
}
|
||||
|
||||
impl Default for Preserve {
|
||||
fn default() -> Self {
|
||||
Self { white: true, black: true, grays: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Preserve {
|
||||
pub const NONE: Self = Self { white: false, black: false, grays: false };
|
||||
|
||||
/// Whether `c` stays as it is.
|
||||
pub fn keeps(&self, c: &Color) -> bool {
|
||||
match Neutral::of(c) {
|
||||
Some(Neutral::White) => self.white,
|
||||
Some(Neutral::Black) => self.black,
|
||||
Some(Neutral::Gray) => self.grays,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the colours of a row take its new colour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
/// Every colour becomes the new colour.
|
||||
#[default]
|
||||
Exact,
|
||||
/// Tints of the row's darkest colour become the same tints of the new colour; other colours
|
||||
/// become the new colour.
|
||||
PreserveTints,
|
||||
/// Every colour becomes a tint of the new colour as light, relative to the row's darkest colour,
|
||||
/// as it is.
|
||||
ScaleTints,
|
||||
/// The colour of average lightness becomes the new colour; lighter and darker ones become tints
|
||||
/// and shades of it.
|
||||
TintsShades,
|
||||
/// The row's most saturated colour becomes the new colour; the others turn by the same hue and
|
||||
/// keep their saturation and brightness.
|
||||
HueShift,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
pub const ALL: [Method; 5] = [Method::Exact, Method::PreserveTints, Method::ScaleTints, Method::TintsShades, Method::HueShift];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Method::Exact => "exact",
|
||||
Method::PreserveTints => "preserveTints",
|
||||
Method::ScaleTints => "scaleTints",
|
||||
Method::TintsShades => "tintsShades",
|
||||
Method::HueShift => "hueShift",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Method::Exact => "Exact",
|
||||
Method::PreserveTints => "Preserve Tints",
|
||||
Method::ScaleTints => "Scale Tints",
|
||||
Method::TintsShades => "Tints and Shades",
|
||||
Method::HueShift => "Hue Shift",
|
||||
}
|
||||
}
|
||||
/// A method from its id or label (case and spaces ignored).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let key = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
let want = key(s);
|
||||
Self::ALL.into_iter().find(|m| key(m.id()) == want || key(m.label()) == want)
|
||||
}
|
||||
|
||||
/// The colour of `row` the new colour stands for: the most saturated for Hue Shift, the one
|
||||
/// of average lightness for Tints and Shades, else the darkest. `None` for an empty row.
|
||||
pub fn key_color(self, row: &[Color]) -> Option<Color> {
|
||||
let labs: Vec<(Color, Lab)> = row.iter().map(|c| (*c, c.to_lab())).collect();
|
||||
let pick = |score: &dyn Fn(&Lab) -> f32| labs.iter().min_by(|a, b| score(&a.1).total_cmp(&score(&b.1))).map(|x| x.0);
|
||||
match self {
|
||||
Method::HueShift => pick(&|l| -l.chroma() * 1000.0 + l.l),
|
||||
Method::TintsShades => {
|
||||
let mean = mean_l(&labs);
|
||||
pick(&|l| (l.l - mean).abs())
|
||||
}
|
||||
_ => pick(&|l| l.l * 1000.0 - l.chroma()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mean_l(labs: &[(Color, Lab)]) -> f32 {
|
||||
labs.iter().map(|x| x.1.l).sum::<f32>() / labs.len().max(1) as f32
|
||||
}
|
||||
|
||||
/// Ink of `c` in `model`'s terms: CMYK and Gray components, 1 − RGB, or Lab's distance from paper
|
||||
/// white (as [`Color::tinted`] scales it).
|
||||
fn ink(c: Color, model: crate::cms::Model) -> [f32; 4] {
|
||||
match c.in_model(model) {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
Color::Rgb { r, g, b } => [1.0 - r, 1.0 - g, 1.0 - b, 0.0],
|
||||
Color::Lab { l, a, b } => [(100.0 - l) / 100.0, a / 100.0, b / 100.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(a: [f32; 4], b: [f32; 4]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// How much of `base`'s ink `c` carries (0 = paper, 1 = `base` or darker), measured in `c`'s model:
|
||||
/// the tint of `base` closest to `c`.
|
||||
pub fn tint_of(c: Color, base: Color) -> f32 {
|
||||
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
|
||||
let bb = dot(b, b);
|
||||
if bb <= 1e-9 { 1.0 } else { (dot(a, b) / bb).clamp(0.0, 1.0) }
|
||||
}
|
||||
|
||||
/// Whether `c` is a tint of `base` (its ink a fraction of `base`'s).
|
||||
pub fn is_tint(c: Color, base: Color) -> bool {
|
||||
let t = tint_of(c, base);
|
||||
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
|
||||
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
|
||||
}
|
||||
|
||||
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
|
||||
pub fn shaded(c: Color, f: f32) -> Color {
|
||||
let f = f.clamp(0.0, 1.0);
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, k + (1.0 - k) * f),
|
||||
Color::Gray { k } => Color::gray(k + (1.0 - k) * f),
|
||||
Color::Lab { l, a, b } => Color::lab(l * (1.0 - f), a * (1.0 - f), b * (1.0 - f)),
|
||||
Color::Rgb { r, g, b } => Color::rgb(r * (1.0 - f), g * (1.0 - f), b * (1.0 - f)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RowMap {
|
||||
method: Method,
|
||||
to: Color,
|
||||
key: Color,
|
||||
mean_l: f32,
|
||||
/// Hue turn from the key colour to the new colour (Hue Shift).
|
||||
turn: f32,
|
||||
}
|
||||
|
||||
impl RowMap {
|
||||
/// The map of a row of current colours `row` (non-empty) to the new colour `to`.
|
||||
pub fn new(method: Method, row: &[Color], to: Color) -> Self {
|
||||
let key = method.key_color(row).unwrap_or(to);
|
||||
let mean_l = match method {
|
||||
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
|
||||
_ => 0.0,
|
||||
};
|
||||
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
|
||||
}
|
||||
|
||||
/// The colour of the row the new colour stands for.
|
||||
pub fn key(&self) -> Color {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// 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);
|
||||
match self.method {
|
||||
Method::Exact => to,
|
||||
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
|
||||
Method::PreserveTints => to,
|
||||
Method::ScaleTints => to.tinted(tint_of(c, self.key)),
|
||||
Method::TintsShades => {
|
||||
let d = c.to_lab().l - self.mean_l;
|
||||
if d >= 0.0 { to.tinted(1.0 - d / (100.0 - self.mean_l).max(1e-3)) } else { shaded(to, -d / self.mean_l.max(1e-3)) }
|
||||
}
|
||||
Method::HueShift if c == self.key => to,
|
||||
Method::HueShift => {
|
||||
let [h, s, v] = c.to_hsb();
|
||||
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group `colors` into at most `n` rows of similar colours, for colour reduction: k-means in Lab
|
||||
/// (lightness counting half, so tints and shades of a hue stay together) weighted by `weights`,
|
||||
/// seeded with the heaviest colour and then farthest-first, so the result is the same on every
|
||||
/// run. Returns the rows as indices into `colors`, ordered by their first index (each in input
|
||||
/// order).
|
||||
pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
|
||||
let pts: Vec<[f32; 3]> = colors
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let l = c.to_lab();
|
||||
[l.l * 0.5, l.a, l.b]
|
||||
})
|
||||
.collect();
|
||||
let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6);
|
||||
let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
|
||||
let n = n.max(1);
|
||||
if pts.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Seeds: the heaviest colour, then each time the one farthest from every seed so far.
|
||||
let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best });
|
||||
let mut centres = vec![pts[first]];
|
||||
let mut near: Vec<f32> = pts.iter().map(|p| d2(p, &pts[first])).collect();
|
||||
while centres.len() < n {
|
||||
let (i, far) = near.iter().enumerate().fold((0, 0.0f32), |b, (i, d)| if *d > b.1 { (i, *d) } else { b });
|
||||
if far <= 1e-6 {
|
||||
break;
|
||||
}
|
||||
centres.push(pts[i]);
|
||||
for (j, p) in pts.iter().enumerate() {
|
||||
near[j] = near[j].min(d2(p, &pts[i]));
|
||||
}
|
||||
}
|
||||
let nearest = |p: &[f32; 3], cs: &[[f32; 3]]| (0..cs.len()).min_by(|a, b| d2(p, &cs[*a]).total_cmp(&d2(p, &cs[*b]))).unwrap_or(0);
|
||||
let mut of: Vec<usize> = pts.iter().map(|p| nearest(p, ¢res)).collect();
|
||||
for _ in 0..50 {
|
||||
let mut sum = vec![([0.0f32; 3], 0.0f32); centres.len()];
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let s = &mut sum[of[i]];
|
||||
for (acc, v) in s.0.iter_mut().zip(p) {
|
||||
*acc += v * w(i);
|
||||
}
|
||||
s.1 += w(i);
|
||||
}
|
||||
for (c, (s, wt)) in centres.iter_mut().zip(&sum) {
|
||||
if *wt > 0.0 {
|
||||
*c = s.map(|v| v / wt);
|
||||
}
|
||||
}
|
||||
let next: Vec<usize> = pts.iter().map(|p| nearest(p, ¢res)).collect();
|
||||
if next == of {
|
||||
break;
|
||||
}
|
||||
of = next;
|
||||
}
|
||||
let mut rows: Vec<Vec<usize>> = vec![vec![]; centres.len()];
|
||||
for (i, c) in of.into_iter().enumerate() {
|
||||
rows[c].push(i);
|
||||
}
|
||||
rows.retain(|r| !r.is_empty());
|
||||
rows.sort_by_key(|r| r[0]);
|
||||
rows
|
||||
}
|
||||
|
||||
/// A set of colours to snap to (Limit to Library), with their Lab values cached.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Palette(Vec<(Color, Lab)>);
|
||||
|
||||
impl Palette {
|
||||
pub fn new(colors: impl IntoIterator<Item = Color>) -> Self {
|
||||
Self(colors.into_iter().map(|c| (c, c.to_lab())).collect())
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
/// The palette colour closest to `c` (CIEDE2000), in its own model; `c` for an empty palette.
|
||||
pub fn nearest(&self, c: Color) -> Color {
|
||||
let l = c.to_lab();
|
||||
self.0.iter().min_by(|a, b| delta_e2000(a.1, l).total_cmp(&delta_e2000(b.1, l))).map_or(c, |x| x.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift64*) for the randomizers.
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
|
||||
}
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
/// Uniform in `lo..hi`.
|
||||
pub fn range(&mut self, lo: f32, hi: f32) -> f32 {
|
||||
lo + (hi - lo) * ((self.next_u64() >> 40) as f32 / (1u64 << 24) as f32)
|
||||
}
|
||||
/// Shuffle `v` in place (Fisher–Yates).
|
||||
pub fn shuffle<T>(&mut self, v: &mut [T]) {
|
||||
for i in (1..v.len()).rev() {
|
||||
let j = (self.next_u64() % (i as u64 + 1)) as usize;
|
||||
v.swap(i, j);
|
||||
}
|
||||
}
|
||||
/// `c` with a random saturation and brightness, its hue kept (greys stay grey), in its model.
|
||||
pub fn saturation_brightness(&mut self, c: Color) -> Color {
|
||||
let [h, s, _] = c.to_hsb();
|
||||
let (s2, v2) = (self.range(0.25, 1.0), self.range(0.3, 1.0));
|
||||
keep_model(c, Color::from_hsb(h, if s <= 0.0 { 0.0 } else { s2 }, v2))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Color, b: Color) -> bool {
|
||||
let v = |c: Color| match c {
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Gray { k } => vec![k],
|
||||
Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0],
|
||||
};
|
||||
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_keep_the_model_and_round_trip() {
|
||||
let colors = [
|
||||
Color::rgb(1.0, 0.5, 0.0),
|
||||
Color::rgb(1.0 / 3.0, 0.2, 0.9),
|
||||
Color::cmyk(0.0, 1.0, 1.0, 0.0),
|
||||
Color::cmyk(0.1234, 0.5, 0.0, 0.07),
|
||||
Color::gray(0.4),
|
||||
];
|
||||
for c in colors {
|
||||
let k = ColorKey::of(&c);
|
||||
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{k}");
|
||||
assert!(close(k.color(), c));
|
||||
}
|
||||
assert_eq!(ColorKey::of(&Color::rgb(1.0, 0.5, 0.0)).to_string(), "rgb 255 127.5 0");
|
||||
assert_eq!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)).to_string(), "cmyk 0 100 100 0");
|
||||
assert_eq!(ColorKey::of(&Color::gray(0.4)).to_string(), "gray 40");
|
||||
assert_eq!(ColorKey::of(&Color::rgb8(18, 52, 86)).to_string(), "rgb 18 52 86", "8-bit colours read as whole levels");
|
||||
// A CMYK red and an RGB red are different colours.
|
||||
assert_ne!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)), ColorKey::of(&Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(ColorKey::parse(" CMYK 0 100 100 0 "), ColorKey::parse("cmyk 0 100 100 0"));
|
||||
let lab = Color::lab(55.0, 60.5, -40.25);
|
||||
assert_eq!(ColorKey::of(&lab).to_string(), "lab 55 60.5 -40.25");
|
||||
assert_eq!(ColorKey::parse("lab 55 60.5 -40.25").map(|k| k.color()), Some(lab));
|
||||
assert_ne!(ColorKey::of(&lab), ColorKey::of(&keep_model(Color::WHITE, lab)), "a Lab colour isn't its RGB lookalike");
|
||||
assert_eq!(ColorKey::parse("#ff0000"), None);
|
||||
assert_eq!(ColorKey::parse("rgb 1 2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutrals_and_preserve() {
|
||||
assert_eq!(Neutral::of(&Color::WHITE), Some(Neutral::White));
|
||||
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.0)), Some(Neutral::White));
|
||||
assert_eq!(Neutral::of(&Color::cmyk(0.6, 0.4, 0.4, 1.0)), Some(Neutral::Black), "rich black");
|
||||
assert_eq!(Neutral::of(&Color::gray(0.5)), Some(Neutral::Gray));
|
||||
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.5, 0.5)), Some(Neutral::Gray));
|
||||
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.3)), Some(Neutral::Gray));
|
||||
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.4, 0.5)), None);
|
||||
assert_eq!(Neutral::of(&Color::lab(100.0, 0.0, 0.0)), Some(Neutral::White));
|
||||
assert_eq!(Neutral::of(&Color::lab(0.0, 0.0, 0.0)), Some(Neutral::Black));
|
||||
assert_eq!(Neutral::of(&Color::lab(50.0, 0.2, -0.3)), Some(Neutral::Gray));
|
||||
assert_eq!(Neutral::of(&Color::lab(50.0, 20.0, 0.0)), None);
|
||||
let p = Preserve::default();
|
||||
assert!(p.keeps(&Color::BLACK) && p.keeps(&Color::WHITE) && !p.keeps(&Color::gray(0.5)));
|
||||
assert!(!Preserve::NONE.keeps(&Color::BLACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_parse_by_id_and_label() {
|
||||
for m in Method::ALL {
|
||||
assert_eq!(Method::parse(m.id()), Some(m));
|
||||
assert_eq!(Method::parse(m.label()), Some(m));
|
||||
}
|
||||
assert_eq!(Method::parse("tints & shades"), Some(Method::TintsShades));
|
||||
assert_eq!(Method::parse("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_tints_keep_tint_ratios() {
|
||||
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
let pink = red.tinted(0.4);
|
||||
let row = [pink, red];
|
||||
let m = RowMap::new(Method::ScaleTints, &row, Color::cmyk(1.0, 0.5, 0.0, 0.0));
|
||||
assert!(close(m.key(), red), "the darkest colour is the key");
|
||||
assert!(close(m.map(red), Color::cmyk(1.0, 0.5, 0.0, 0.0)));
|
||||
assert!(close(m.map(pink), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{:?}", m.map(pink));
|
||||
// RGB tints mix toward white.
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let m = RowMap::new(Method::ScaleTints, &[blue, blue.tinted(0.5)], Color::rgb(1.0, 0.0, 0.0));
|
||||
assert!(close(m.map(blue.tinted(0.5)), Color::rgb(1.0, 0.5, 0.5)));
|
||||
// Lab tints too (toward paper white).
|
||||
let base = Color::lab(40.0, 60.0, -20.0);
|
||||
let to = Color::lab(50.0, -40.0, 30.0);
|
||||
let m = RowMap::new(Method::ScaleTints, &[base, base.tinted(0.25)], to);
|
||||
assert!(close(m.map(base.tinted(0.25)), to.tinted(0.25)), "{:?}", m.map(base.tinted(0.25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_tints_only_scales_real_tints() {
|
||||
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
let other = Color::cmyk(0.0, 0.3, 0.8, 0.0);
|
||||
let to = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
let m = RowMap::new(Method::PreserveTints, &[red, red.tinted(0.5), other], to);
|
||||
assert!(close(m.map(red.tinted(0.5)), Color::cmyk(0.5, 0.0, 0.0, 0.0)));
|
||||
assert!(close(m.map(other), to), "not a tint: exact");
|
||||
let s = RowMap::new(Method::ScaleTints, &[red, other], to);
|
||||
assert!(!close(s.map(other), to), "Scale Tints scales every colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_tints_shades_and_hue_shift() {
|
||||
let to = Color::rgb(0.0, 0.4, 1.0);
|
||||
let row = [Color::rgb(0.9, 0.2, 0.2), Color::rgb(0.5, 0.1, 0.1), Color::rgb(1.0, 0.7, 0.7)];
|
||||
let e = RowMap::new(Method::Exact, &row, to);
|
||||
assert!(row.iter().all(|c| e.map(*c) == to));
|
||||
let ts = RowMap::new(Method::TintsShades, &row, to);
|
||||
let l = |c: Color| c.to_lab().l;
|
||||
assert!(l(ts.map(row[2])) > l(ts.map(row[0])) && l(ts.map(row[0])) > l(ts.map(row[1])), "lighter stays lighter");
|
||||
let hs = RowMap::new(Method::HueShift, &row, to);
|
||||
assert_eq!(hs.map(hs.key()), to);
|
||||
let [h, s, v] = hs.map(row[1]).to_hsb();
|
||||
let [_, s0, v0] = row[1].to_hsb();
|
||||
assert!((h - to.to_hsb()[0]).abs() < 1.0 && (s - s0).abs() < 1e-3 && (v - v0).abs() < 1e-3, "{h} {s} {v}");
|
||||
// Results keep the model of the colour they replace.
|
||||
let m = RowMap::new(Method::Exact, &[Color::gray(0.3)], to);
|
||||
assert!(matches!(m.map(Color::gray(0.3)), Color::Gray { .. }));
|
||||
let lab = [Color::lab(40.0, 50.0, 30.0), Color::lab(70.0, 20.0, 10.0)];
|
||||
for method in Method::ALL {
|
||||
let m = RowMap::new(method, &lab, to);
|
||||
assert!(lab.iter().all(|c| matches!(m.map(*c), Color::Lab { .. })), "{}", method.label());
|
||||
}
|
||||
assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_reduces_to_n_rows_of_similar_colours() {
|
||||
let colors =
|
||||
[Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.9, 0.1, 0.05), Color::rgb(0.1, 0.1, 0.9), Color::rgb(1.0, 0.2, 0.2)];
|
||||
let rows = cluster(&colors, &[5.0, 4.0, 1.0, 1.0, 1.0], 2);
|
||||
assert_eq!(rows, vec![vec![0, 2, 4], vec![1, 3]]);
|
||||
assert_eq!(cluster(&colors, &[1.0; 5], 9).len(), 5, "never more rows than colours");
|
||||
assert_eq!(cluster(&colors, &[1.0; 5], 1), vec![vec![0, 1, 2, 3, 4]]);
|
||||
assert_eq!(cluster(&[Color::WHITE, Color::WHITE], &[1.0, 1.0], 2).len(), 1, "identical colours share a row");
|
||||
assert!(cluster(&[], &[], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_snaps_to_the_nearest_colour() {
|
||||
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
|
||||
assert_eq!(p.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::rgb(1.0, 0.0, 0.0));
|
||||
assert_eq!(p.nearest(Color::rgb(0.45, 0.45, 0.45)), Color::gray(0.5));
|
||||
let lab = Palette::new([Color::lab(50.0, 70.0, 50.0), Color::lab(50.0, -60.0, 50.0)]);
|
||||
assert_eq!(lab.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::lab(50.0, 70.0, 50.0), "library colours keep their model");
|
||||
assert_eq!(Palette::default().nearest(Color::WHITE), Color::WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn randomizers_are_deterministic_and_keep_hue() {
|
||||
let mut a: Vec<u32> = (0..10).collect();
|
||||
let mut b = a.clone();
|
||||
Rng::new(7).shuffle(&mut a);
|
||||
Rng::new(7).shuffle(&mut b);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, (0..10).collect::<Vec<_>>());
|
||||
let c = Color::cmyk(0.0, 0.8, 0.9, 0.1);
|
||||
let r = Rng::new(3).saturation_brightness(c);
|
||||
assert!(matches!(r, Color::Cmyk { .. }));
|
||||
assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. }));
|
||||
assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cms::Model;
|
||||
use crate::{Color, Gradient, GradientKind, GradientStop, Paint};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -16,6 +17,30 @@ pub struct Swatch {
|
||||
pub spot: bool,
|
||||
}
|
||||
|
||||
/// The name of the built-in Registration swatch ([`registration`]).
|
||||
pub const REGISTRATION: &str = "[Registration]";
|
||||
|
||||
/// The built-in Registration swatch: a global colour of 100% of every ink that prints on every
|
||||
/// plate, process and spot (printer's marks). Every document has it; it can't be edited or deleted.
|
||||
pub fn registration() -> &'static Swatch {
|
||||
static S: std::sync::OnceLock<Swatch> = std::sync::OnceLock::new();
|
||||
S.get_or_init(|| Swatch { name: REGISTRATION.into(), paint: Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0)), global: true, spot: false })
|
||||
}
|
||||
|
||||
impl Swatch {
|
||||
/// A tint swatch's base swatch and tint: its colour links to another (global) swatch.
|
||||
pub fn tint_of(&self) -> Option<(&str, f32)> {
|
||||
match &self.paint {
|
||||
Paint::Solid { swatch: Some(base), tint, .. } => Some((base, *tint)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// None and Registration: built in, they can't be edited, moved or deleted.
|
||||
pub fn is_reserved(&self) -> bool {
|
||||
self.paint.is_none() || self.name == REGISTRATION
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SwatchGroup {
|
||||
pub name: String,
|
||||
@@ -23,12 +48,36 @@ pub struct SwatchGroup {
|
||||
}
|
||||
|
||||
fn solid(name: &str, hex: &str) -> Swatch {
|
||||
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false }
|
||||
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)), global: false, spot: false }
|
||||
}
|
||||
|
||||
/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours
|
||||
/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray.
|
||||
pub fn default_swatches(model: Model) -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let (mut s, mut g) = base_swatches();
|
||||
if model == Model::Cmyk {
|
||||
let cmyk = |c: &mut Color| {
|
||||
if let Color::Rgb { .. } = c {
|
||||
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0;
|
||||
let [cc, m, y, k] = c.to_cmyk();
|
||||
*c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k));
|
||||
}
|
||||
};
|
||||
for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
|
||||
match &mut w.paint {
|
||||
Paint::Solid { color, .. } => cmyk(color),
|
||||
Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(s, g)
|
||||
}
|
||||
|
||||
/// The default document swatches: None, Registration-like black, white, black, a spectrum, greys,
|
||||
/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.)
|
||||
pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
/// gradients. (The composition is ours; the reference app's layout grammar — None first, then
|
||||
/// specials — is kept.)
|
||||
fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let mut s =
|
||||
vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")];
|
||||
let spectrum = [
|
||||
@@ -77,8 +126,8 @@ pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
|
||||
kind,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
|
||||
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
|
||||
],
|
||||
}))),
|
||||
global: false,
|
||||
@@ -110,10 +159,40 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn defaults_start_with_none() {
|
||||
let (s, g) = default_swatches();
|
||||
let (s, g) = default_swatches(Model::Rgb);
|
||||
assert!(s[0].paint.is_none());
|
||||
assert!(s.len() > 30);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert_eq!(g[0].swatches.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_defaults_are_cmyk_with_the_same_names() {
|
||||
let (rgb, _) = default_swatches(Model::Rgb);
|
||||
let (s, g) = default_swatches(Model::Cmyk);
|
||||
assert_eq!(s.iter().map(|w| &w.name).collect::<Vec<_>>(), rgb.iter().map(|w| &w.name).collect::<Vec<_>>());
|
||||
let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint {
|
||||
Paint::Solid { color, .. } => vec![*color],
|
||||
Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(),
|
||||
_ => vec![],
|
||||
});
|
||||
assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. })));
|
||||
assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray");
|
||||
let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color());
|
||||
assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0)));
|
||||
let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color());
|
||||
assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_swatches_parse() {
|
||||
// `solid`/`grad` fall back to black or white on a bad literal instead of panicking; make sure
|
||||
// none falls back.
|
||||
let src = include_str!("swatch.rs");
|
||||
let hexes: Vec<&str> = src.split('"').filter(|t| t.starts_with('#') && t.len() == 7).collect();
|
||||
assert!(hexes.len() > 30);
|
||||
for h in hexes {
|
||||
assert!(Color::from_hex(h).is_some(), "{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to
|
||||
//! export on their own, at the export settings Export for Screens shares.
|
||||
//!
|
||||
//! An asset names objects rather than copying them: it follows edits (a moved object moves the
|
||||
//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted
|
||||
//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the
|
||||
//! document's id counter, so they never repeat within a document.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Document, NodeId};
|
||||
|
||||
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
|
||||
/// visual bounds.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub nodes: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
|
||||
const ASSET: &str = "Asset ";
|
||||
|
||||
impl Document {
|
||||
/// Asset `id`.
|
||||
pub fn asset(&self, id: u64) -> Option<&ExportAsset> {
|
||||
self.assets.iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
|
||||
pub fn next_asset_name(&self) -> String {
|
||||
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
|
||||
format!("{ASSET}{}", n.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Forget the objects of assets that are no longer in the document, and the assets left with
|
||||
/// none (after every edit).
|
||||
pub fn prune_assets(&mut self) {
|
||||
if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) {
|
||||
return;
|
||||
}
|
||||
let mut assets = std::mem::take(&mut self.assets);
|
||||
for a in &mut assets {
|
||||
a.nodes.retain(|id| self.node(*id).is_some());
|
||||
}
|
||||
assets.retain(|a| !a.nodes.is_empty());
|
||||
self.assets = assets;
|
||||
}
|
||||
|
||||
/// `ids` that are in the document, back to front.
|
||||
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
|
||||
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
|
||||
v.sort();
|
||||
v.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Clipped art read from page descriptions (metafiles, PostScript) into clipping groups.
|
||||
//!
|
||||
//! Importers record each object they draw with the chain of clips in force at the time, outermost
|
||||
//! first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into one clipping
|
||||
//! group, nesting groups for nested clips, keeping the drawing order.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::{Document, Node, NodeKind};
|
||||
|
||||
/// A clip: a region in document space and its rule. Each has an id unique within an import, so
|
||||
/// what one clip clips can be grouped.
|
||||
#[derive(Debug)]
|
||||
pub struct Clip {
|
||||
pub id: u32,
|
||||
pub path: BezPath,
|
||||
pub rule: FillRule,
|
||||
}
|
||||
|
||||
/// Objects in drawing order, each with its clips (outermost first).
|
||||
pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
|
||||
|
||||
/// The nodes of `items`, those under clips grouped into clipping groups (ids from `doc`), in
|
||||
/// order.
|
||||
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
|
||||
nest_at(doc, items, 0)
|
||||
}
|
||||
|
||||
/// [`nest`] for the clips at `depth` and deeper.
|
||||
fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
|
||||
let mut out: Vec<Arc<Node>> = vec![];
|
||||
let mut group: Drawn = vec![];
|
||||
let mut clip: Option<Arc<Clip>> = None;
|
||||
for (chain, node) in items {
|
||||
let here = chain.get(depth).cloned();
|
||||
if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) {
|
||||
if let Some(c) = clip.take() {
|
||||
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth));
|
||||
}
|
||||
clip = here.clone();
|
||||
}
|
||||
match here {
|
||||
Some(_) => group.push((chain, node)),
|
||||
None => out.push(Arc::new(node)),
|
||||
}
|
||||
}
|
||||
if let Some(c) = clip {
|
||||
out.push(clip_group(doc, &c, group, depth));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A clipping group of `items`, clipped by `c` (their clip at `depth`).
|
||||
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
|
||||
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default());
|
||||
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
*rule = c.rule;
|
||||
}
|
||||
let mut children = vec![Arc::new(path)];
|
||||
children.extend(nest_at(doc, items, depth + 1));
|
||||
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true }))
|
||||
}
|
||||
@@ -20,15 +20,18 @@ pub struct Hit {
|
||||
pub leaf: NodeId,
|
||||
pub ancestry: Vec<NodeId>,
|
||||
pub kind: HitKind,
|
||||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||||
/// its content, not the envelope, is the object clicked.
|
||||
pub contents_of: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||||
if let Some(s) = scope
|
||||
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
|
||||
{
|
||||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||||
}
|
||||
// Skip layers and sublayers.
|
||||
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
|
||||
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -76,13 +80,27 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
|
||||
fn edits_contents(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
|
||||
}
|
||||
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
let frame = |n: &Node| {
|
||||
let b = n.geometric_bounds()?;
|
||||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||||
};
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
|
||||
let children = parent.children()?;
|
||||
// A clip group only hits inside its clipping path.
|
||||
if let NodeKind::Group { clip: true, .. } = parent.kind
|
||||
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
|
||||
if parent.clips()
|
||||
&& let Some(clip) = children.first()
|
||||
&& let Some(path) = clip.path_data()
|
||||
&& !fill_contains(&path.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p)
|
||||
&& !clip_contains(clip, p)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -90,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
if !c.visible || c.locked {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = c.visual_bounds()
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !edits_contents(c)
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
continue;
|
||||
@@ -98,7 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
@@ -108,13 +134,22 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
None
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::FillRule, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) {
|
||||
return Some(HitKind::Stroke);
|
||||
}
|
||||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
let sw = n.appearance.stroke_width();
|
||||
if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) {
|
||||
return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke });
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
}
|
||||
let filled = !n.appearance.fill_paint().is_none();
|
||||
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
|
||||
@@ -122,15 +157,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
}
|
||||
None
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
for c in children {
|
||||
if let Some(pd) = c.path_data() {
|
||||
bp.extend(pd.to_bezpath());
|
||||
}
|
||||
}
|
||||
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
|
||||
return Some(HitKind::Stroke);
|
||||
NodeKind::Compound { rule, .. } => {
|
||||
let bp = n.stroke_path()?;
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
}
|
||||
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Printing inks: the plates of a document (four process inks and its spot inks), separating a
|
||||
//! colour into them ([`inks`]), and visiting every colour of the art with its swatch link (the
|
||||
//! Separations Preview recolours the art with these; print separates it into plates).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::cms::{Cms, Intent, PROCESS_PLATES};
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use crate::{AppearanceItem, Document, Node, NodeKind};
|
||||
|
||||
/// Ink coverage of one paint colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Inks {
|
||||
/// Process inks C, M, Y, K (0..1).
|
||||
pub cmyk: [f32; 4],
|
||||
/// Spot ink (swatch name, tint 0..1).
|
||||
pub spot: Option<(String, f32)>,
|
||||
/// Registration: the tint (every process ink's) prints on every spot plate too.
|
||||
pub registration: bool,
|
||||
}
|
||||
|
||||
impl Inks {
|
||||
/// The tint this colour prints on spot plate `name`.
|
||||
pub fn spot_tint(&self, name: &str) -> f32 {
|
||||
match &self.spot {
|
||||
_ if self.registration => self.cmyk[0],
|
||||
Some((n, t)) if n == name => *t,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ink this colour prints on `plate` (a process plate name or a spot swatch name).
|
||||
pub fn on_plate(&self, plate: &str) -> f32 {
|
||||
match PROCESS_PLATES.iter().position(|p| *p == plate) {
|
||||
Some(i) => self.cmyk[i],
|
||||
None => self.spot_tint(plate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A printing plate.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Plate {
|
||||
pub name: String,
|
||||
pub spot: bool,
|
||||
/// Display colour of the ink (for the panel swatch).
|
||||
pub rgb: [f32; 3],
|
||||
}
|
||||
|
||||
/// The plates of `doc`: the four process plates plus one per spot swatch.
|
||||
pub fn plates(doc: &Document) -> Vec<Plate> {
|
||||
let c = vectorcraft_color::cms::active();
|
||||
let mut v: Vec<Plate> = PROCESS_PLATES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut ink = [0.0; 4];
|
||||
ink[i] = 1.0;
|
||||
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
|
||||
})
|
||||
.collect();
|
||||
for sw in doc.swatches_iter() {
|
||||
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
|
||||
&& !v.iter().any(|p| p.name == sw.name)
|
||||
{
|
||||
v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(&doc.linked_color(*color, true)) });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The ink colour of spot swatch `name` as it shows ([`Document::linked_color`]).
|
||||
pub fn spot_color(doc: &Document, name: &str) -> Option<Color> {
|
||||
let s = doc.swatch(name).filter(|s| s.spot)?;
|
||||
s.paint.color().map(|c| doc.linked_color(c, true))
|
||||
}
|
||||
|
||||
/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it.
|
||||
pub type Link<'a> = Option<(&'a str, f32)>;
|
||||
|
||||
/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at
|
||||
/// its tint; one linked to the Registration swatch prints its tint on every plate.
|
||||
pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks {
|
||||
if let Some((REGISTRATION, tint)) = link {
|
||||
return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true };
|
||||
}
|
||||
if let Some((name, tint)) = link
|
||||
&& doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some())
|
||||
{
|
||||
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false };
|
||||
}
|
||||
Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false }
|
||||
}
|
||||
|
||||
/// Visits one colour: the colour, its swatch link and its tint, all of which it may change.
|
||||
pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) + 'a;
|
||||
|
||||
fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, tint } => f(color, swatch, tint),
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
f(&mut s.color, &mut s.swatch, &mut s.tint);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
|
||||
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
|
||||
/// dropped.
|
||||
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) => visit_paint(&mut l.paint, f),
|
||||
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
visit_paint(&mut r.style.fill, f);
|
||||
visit_paint(&mut r.style.stroke, f);
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => {
|
||||
for p in &mut m.points {
|
||||
f(&mut p.color, &mut None, &mut 1.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
visit_node_colors(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
|
||||
/// points) given its swatch link and tint, keeping the links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint))));
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour in the document's art and symbol definitions.
|
||||
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
for l in &mut doc.layers {
|
||||
map_node_colors(Arc::make_mut(l), f);
|
||||
}
|
||||
for s in &mut doc.symbols {
|
||||
map_node_colors(Arc::make_mut(&mut s.art), f);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,33 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
pub mod inks;
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
pub mod range;
|
||||
pub mod rastersettings;
|
||||
mod reach;
|
||||
pub mod recolor;
|
||||
pub mod selection;
|
||||
pub mod setup;
|
||||
pub mod slices;
|
||||
pub mod style_libs;
|
||||
pub mod swatches;
|
||||
pub mod text;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -30,16 +51,34 @@ pub(crate) mod skip {
|
||||
}
|
||||
}
|
||||
|
||||
pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
pub use appearance::StrokeGradientMode;
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
|
||||
StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
|
||||
WrapShape,
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
@@ -135,15 +174,48 @@ impl Unit {
|
||||
}
|
||||
/// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`).
|
||||
pub fn format(self, pt: f64) -> String {
|
||||
let v = self.from_pt(pt);
|
||||
let s = format!("{:.3}", v);
|
||||
format!("{} {}", self.number(pt), self.suffix())
|
||||
}
|
||||
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
|
||||
pub fn number(self, pt: f64) -> String {
|
||||
let s = format!("{:.3}", self.from_pt(pt));
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
let s = if s == "-0" { "0" } else { s };
|
||||
format!("{s} {}", self.suffix())
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
/// 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.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self)
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
|
||||
/// `feetInches`).
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Unit::Points => "points",
|
||||
Unit::Picas => "picas",
|
||||
Unit::Inches => "inches",
|
||||
Unit::Millimeters => "millimeters",
|
||||
Unit::Centimeters => "centimeters",
|
||||
Unit::Pixels => "pixels",
|
||||
Unit::FeetInches => "feetInches",
|
||||
Unit::Meters => "meters",
|
||||
Unit::Yards => "yards",
|
||||
Unit::Feet => "feet",
|
||||
}
|
||||
}
|
||||
/// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and
|
||||
/// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
pub fn readout(self, pt: f64) -> String {
|
||||
format!("{:.2} {}", self.from_pt(pt), self.suffix())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,19 +223,27 @@ 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 ['+', '-', '*', '/'] {
|
||||
if let Some(i) = s[1.min(s.len())..].rfind(op).map(|i| i + 1.min(s.len())) {
|
||||
// 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) {
|
||||
let (l, r) = (&s[..i], &s[i + 1..]);
|
||||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let a = parse_measure(l, default)?;
|
||||
return match op {
|
||||
'+' => Some(a + parse_measure(r, default)?),
|
||||
'-' => Some(a - parse_measure(r, default)?),
|
||||
'*' => Some(a * r.trim().parse::<f64>().ok()?),
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
_ => {
|
||||
let d = r.trim().parse::<f64>().ok()?;
|
||||
if d == 0.0 { None } else { Some(a / d) }
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
} else if k == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(a / k)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -177,22 +257,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
}
|
||||
|
||||
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
|
||||
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
let s = s.trim();
|
||||
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let v: f64 = s[..num_end].trim().parse().ok()?;
|
||||
let unit = match s[num_end..].trim() {
|
||||
"" => default,
|
||||
"pt" => Unit::Points,
|
||||
"px" => Unit::Pixels,
|
||||
"in" | "\"" => Unit::Inches,
|
||||
"mm" => Unit::Millimeters,
|
||||
"cm" => Unit::Centimeters,
|
||||
"m" => Unit::Meters,
|
||||
"ft" | "'" => Unit::Feet,
|
||||
"yd" => Unit::Yards,
|
||||
"pc" => Unit::Picas,
|
||||
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
|
||||
let unit = match s.get(num_end..)?.trim() {
|
||||
"" => None,
|
||||
"pt" => Some(Unit::Points),
|
||||
"px" => Some(Unit::Pixels),
|
||||
"in" | "\"" => Some(Unit::Inches),
|
||||
"mm" => Some(Unit::Millimeters),
|
||||
"cm" => Some(Unit::Centimeters),
|
||||
"m" => Some(Unit::Meters),
|
||||
"ft" | "'" => Some(Unit::Feet),
|
||||
"yd" => Some(Unit::Yards),
|
||||
"p" | "pc" => Some(Unit::Picas),
|
||||
_ => return None,
|
||||
};
|
||||
Some(unit.to_pt(v))
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
// A leading `+` is a sign (`+5`), as a plain number read it.
|
||||
let s = s.trim();
|
||||
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -202,6 +300,16 @@ pub enum ColorMode {
|
||||
Cmyk,
|
||||
}
|
||||
|
||||
impl ColorMode {
|
||||
/// The colour model new colours take in a document of this mode.
|
||||
pub fn model(self) -> vectorcraft_color::cms::Model {
|
||||
match self {
|
||||
ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb,
|
||||
ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Artboard {
|
||||
pub id: u32,
|
||||
@@ -250,11 +358,80 @@ impl Default for GridPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// A named graphic style (Graphic Styles panel).
|
||||
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GraphicStyle {
|
||||
pub name: String,
|
||||
pub appearance: Appearance,
|
||||
/// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles
|
||||
/// from older files get one when an object is first linked to them).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub id: u32,
|
||||
#[serde(default = "one", skip_serializing_if = "skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub blend: vectorcraft_color::BlendMode,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub isolate: bool,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub knockout: Knockout,
|
||||
/// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style
|
||||
/// places them on each object's own bounds. Styles saved before this kept document
|
||||
/// coordinates (false).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub unit_box: bool,
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// The name of the style new documents list first (Sort by Name keeps it first).
|
||||
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
|
||||
|
||||
impl GraphicStyle {
|
||||
/// The box a style's placed gradients are stored relative to.
|
||||
pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
/// A style with `appearance` (its placed gradients, if any, in unit-box space) and default
|
||||
/// transparency (no id yet).
|
||||
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
appearance,
|
||||
id: 0,
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Knockout::Neutral,
|
||||
unit_box: true,
|
||||
}
|
||||
}
|
||||
/// A style capturing `n`'s transparency and `appearance` (in unit-box space).
|
||||
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
|
||||
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
|
||||
}
|
||||
/// The style's appearance as object `n` takes it: placed gradients stored in unit-box space
|
||||
/// land at the same place relative to `n`'s geometric bounds.
|
||||
pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> {
|
||||
let placed = self.unit_box && self.appearance.has_placed_gradient();
|
||||
match n.geometric_bounds().filter(|_| placed) {
|
||||
Some(b) => {
|
||||
let mut ap = self.appearance.clone();
|
||||
ap.rebase_gradients(Self::UNIT_BOX, b);
|
||||
std::borrow::Cow::Owned(ap)
|
||||
}
|
||||
None => std::borrow::Cow::Borrowed(&self.appearance),
|
||||
}
|
||||
}
|
||||
/// Give `n` this style's transparency.
|
||||
pub fn apply_transparency(&self, n: &mut Node) {
|
||||
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
|
||||
}
|
||||
/// Does `n` have this style's transparency?
|
||||
pub fn transparency_matches(&self, n: &Node) -> bool {
|
||||
(n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout)
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol definition.
|
||||
@@ -270,6 +447,10 @@ pub struct ImageBlob {
|
||||
pub mime: String,
|
||||
#[serde(skip)]
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only
|
||||
/// linked images show this blob, and what `bytes` hold while the linked file can't be read.
|
||||
#[serde(skip)]
|
||||
pub proxy: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -321,31 +502,103 @@ pub struct Document {
|
||||
/// Pattern editing mode, while active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pattern_edit: Option<PatternEdit>,
|
||||
/// Opacity-mask editing mode, while active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Opacity-mask editing mode, while active. Never saved (see [`Document::without_edit_modes`]);
|
||||
/// still read so files saved mid-edit by older versions load without the editing layer.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub mask_edit: Option<MaskEdit>,
|
||||
next_id: u64,
|
||||
/// Foreign data preserved on round-trip.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub unknown: serde_json::Map<String, serde_json::Value>,
|
||||
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
|
||||
/// so top-level blend modes don't blend with what lies under the page.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub page_isolate: bool,
|
||||
/// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers
|
||||
/// pass their contents through) knock each other out.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub page_knockout: bool,
|
||||
/// Swatches panel → Spot Colors: spot colours defined in Lab show and separate from their Lab
|
||||
/// values (on, the default) or from their working-CMYK equivalents (off)
|
||||
/// ([`Document::linked_color`]).
|
||||
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
|
||||
pub spot_use_lab: bool,
|
||||
/// File → Document Setup (bleed, transparency grid, paper, type options).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub setup: DocSetup,
|
||||
/// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name)
|
||||
/// they were copied from instead of the current layer.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub paste_remembers_layers: bool,
|
||||
/// File → File Info (the title is [`Document::title`]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub metadata: DocMetadata,
|
||||
/// Effect → Document Raster Effects Settings besides the resolution
|
||||
/// ([`Document::raster_effects_ppi`]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub raster_effects: RasterEffectsSettings,
|
||||
/// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at
|
||||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
|
||||
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
|
||||
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
|
||||
pub color_profiles: ColorProfiles,
|
||||
/// File → Export for Screens: the settings it last exported with (`document.exportForScreens`
|
||||
/// params), so the dialog reopens on them.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub export_settings: serde_json::Map<String, serde_json::Value>,
|
||||
/// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't
|
||||
/// lose them. Separate from [`Document::unknown`], which holds foreign data by design.
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||
/// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`].
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<Slice>,
|
||||
/// Object → Slice → Clip to Artboard: slices are clipped to the artboards and auto slices fill
|
||||
/// them (on, the default); off, auto slices cover the art and the slices.
|
||||
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
|
||||
pub slices_clip_to_artboard: bool,
|
||||
/// File → Print: the print settings saved with the document (`vectorcraft_pdf::PrintSettings`
|
||||
/// as JSON: the print engine sits above this crate); `None` until they are set up.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub print_setup: Option<serde_json::Value>,
|
||||
/// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their
|
||||
/// export settings are Export for Screens' ([`Document::export_settings`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<ExportAsset>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
72.0
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// A new document with one artboard of `size` and one layer ("Layer 1").
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
let (swatches, swatch_groups) = vectorcraft_color::default_swatches();
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
}
|
||||
|
||||
/// [`Document::new`] in colour `mode`, with that mode's default swatches.
|
||||
pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self {
|
||||
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model());
|
||||
let mut d = Self {
|
||||
version: FORMAT_VERSION,
|
||||
title: "Untitled-1".into(),
|
||||
template: false,
|
||||
units: Unit::Points,
|
||||
color_mode: ColorMode::Rgb,
|
||||
color_mode: mode,
|
||||
artboards: vec![Artboard {
|
||||
id: 1,
|
||||
name: "Artboard 1".into(),
|
||||
@@ -371,6 +624,22 @@ impl Document {
|
||||
mask_edit: None,
|
||||
next_id: 1,
|
||||
unknown: Default::default(),
|
||||
page_isolate: false,
|
||||
page_knockout: false,
|
||||
spot_use_lab: true,
|
||||
setup: DocSetup::default(),
|
||||
paste_remembers_layers: false,
|
||||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
color_profiles: ColorProfiles::default(),
|
||||
export_settings: Default::default(),
|
||||
extra: Default::default(),
|
||||
slices: vec![],
|
||||
slices_clip_to_artboard: true,
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
@@ -392,6 +661,8 @@ impl Document {
|
||||
for l in &self.layers {
|
||||
l.walk(&mut |n| max = max.max(n.id.0));
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||||
self.next_id = self.next_id.max(max + 1);
|
||||
}
|
||||
|
||||
@@ -496,8 +767,11 @@ impl Document {
|
||||
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
|
||||
let parent = self.parent_of(id);
|
||||
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
|
||||
let idx = *path.last().unwrap();
|
||||
let idx = *path.last().ok_or(DocError::NoNode(id))?;
|
||||
let ch = self.children_mut(parent)?;
|
||||
if idx >= ch.len() {
|
||||
return Err(DocError::NoNode(id));
|
||||
}
|
||||
Ok(ch.remove(idx))
|
||||
}
|
||||
|
||||
@@ -594,36 +868,92 @@ impl Document {
|
||||
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
|
||||
self.patterns.iter_mut().find(|p| p.name == name)
|
||||
}
|
||||
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
|
||||
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
|
||||
}
|
||||
/// Deep-clone `node` with fresh ids for it and all descendants.
|
||||
pub fn reid(&mut self, node: &Node) -> Node {
|
||||
let mut n = node.clone();
|
||||
n.id = self.alloc_id();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
let fresh: Vec<Arc<Node>> = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
*n.children_mut().unwrap() = fresh;
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||||
/// mask already holds (the engine syncs it after every edit).
|
||||
pub fn drop_edit_modes(&mut self) {
|
||||
if let Some(me) = self.mask_edit.take() {
|
||||
self.layers.retain(|l| l.id != me.layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
|
||||
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
|
||||
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.mask_edit.is_none() {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
d.drop_edit_modes();
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphic styles.
|
||||
impl Document {
|
||||
/// Index of the graphic style named `name`.
|
||||
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
|
||||
self.graphic_styles.iter().position(|g| g.name == name)
|
||||
}
|
||||
/// The graphic style named `name`.
|
||||
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
|
||||
self.graphic_styles.iter().find(|g| g.name == name)
|
||||
}
|
||||
/// The graphic style with id `id` (0 never matches).
|
||||
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
|
||||
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
|
||||
}
|
||||
/// An id no graphic style has.
|
||||
pub fn next_graphic_style_id(&self) -> u32 {
|
||||
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
/// The id of graphic style `index`, assigning one if it has none yet.
|
||||
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
|
||||
if self.graphic_styles[index].id == 0 {
|
||||
self.graphic_styles[index].id = self.next_graphic_style_id();
|
||||
}
|
||||
self.graphic_styles[index].id
|
||||
}
|
||||
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
|
||||
/// from the number of styles.
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
vec![
|
||||
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() },
|
||||
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) },
|
||||
GraphicStyle {
|
||||
name: "Heavy Ink".into(),
|
||||
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0),
|
||||
},
|
||||
GraphicStyle {
|
||||
name: "Sunshine".into(),
|
||||
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
|
||||
},
|
||||
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK));
|
||||
[
|
||||
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
|
||||
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
|
||||
GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
|
||||
GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
|
||||
]
|
||||
.into_iter()
|
||||
.zip(1..)
|
||||
.map(|(g, id)| GraphicStyle { id, ..g })
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
|
||||
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
|
||||
pub fn reserve_ids(&mut self, next: u64) {
|
||||
self.next_id = self.next_id.max(next);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -717,6 +1047,43 @@ mod tests {
|
||||
assert!(!ids.contains(&a) && !ids.contains(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_round_trips_and_old_files_load() {
|
||||
let (mut d, _, _) = doc_with_rects();
|
||||
// A document without a setup writes no `setup` key (old readers and files stay unchanged).
|
||||
assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\""));
|
||||
d.setup.bleed = [9.0, 9.0, 0.0, 18.0];
|
||||
d.setup.grid_size = GridSize::Large;
|
||||
d.setup.typographers_quotes = false;
|
||||
d.setup.quotes = setup::language_quotes("German").unwrap();
|
||||
d.setup.flattener_preset = Some("High Resolution".into());
|
||||
d.setup.background = Background::White;
|
||||
d.setup.export_text = ExportText::Appearance;
|
||||
let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.setup, d.setup);
|
||||
// Files written before Document Setup existed load with the defaults.
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v.as_object_mut().unwrap().remove("setup");
|
||||
let old: Document = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.setup, DocSetup::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_position_round_trips() {
|
||||
let st = CharStyle {
|
||||
position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }),
|
||||
small_caps: Some(75.0),
|
||||
..CharStyle::default()
|
||||
};
|
||||
let v = serde_json::to_value(&st).unwrap();
|
||||
assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0}));
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(v).unwrap(), st);
|
||||
let plain = serde_json::to_value(CharStyle::default()).unwrap();
|
||||
assert!(plain.get("position").is_none() && plain.get("small_caps").is_none());
|
||||
let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0);
|
||||
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units() {
|
||||
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
|
||||
@@ -731,3 +1098,8 @@ mod tests {
|
||||
assert_eq!(Unit::Inches.format(144.0), "2 in");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the
|
||||
//! low-resolution preview a linked image keeps for when its file can't be read
|
||||
//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
|
||||
/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their
|
||||
/// full pixels are saved instead.
|
||||
pub const PROXY_SIZE: u32 = 256;
|
||||
/// The MIME type of previews.
|
||||
pub const PROXY_MIME: &str = "image/png";
|
||||
|
||||
/// A linked file: an image object shows the file at `path` (File → Place with Link) and the
|
||||
/// document saves only a preview of it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinkInfo {
|
||||
/// The file's absolute path.
|
||||
pub path: String,
|
||||
/// The path relative to the document's folder (`/` separators), written on save: how the file
|
||||
/// is found after the document and its links move together.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relative: Option<String>,
|
||||
/// The file's modification time when last read (milliseconds since the Unix epoch).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modified: Option<u64>,
|
||||
/// The file's size when last read (bytes).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// The hash of the file's bytes when last read ([`hash_bytes`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
/// The page shown, for a linked document page (1-based).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<u32>,
|
||||
}
|
||||
|
||||
impl LinkInfo {
|
||||
/// A link to `path` with nothing known about the file.
|
||||
pub fn new(path: impl Into<String>) -> Self {
|
||||
Self { path: path.into(), ..Self::default() }
|
||||
}
|
||||
|
||||
/// The file name: the path's last component, whichever system's separators it uses.
|
||||
pub fn name(&self) -> &str {
|
||||
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for LinkInfo {
|
||||
fn from(path: &str) -> Self {
|
||||
Self::new(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path.
|
||||
pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Repr {
|
||||
Path(String),
|
||||
Info(LinkInfo),
|
||||
}
|
||||
Ok(Option::<Repr>::deserialize(d)?.map(|r| match r {
|
||||
Repr::Path(path) => LinkInfo::new(path),
|
||||
Repr::Info(info) => info,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
|
||||
pub fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
|
||||
format!("img{h:016x}")
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A blob of encoded image `bytes` of type `mime`, without a preview.
|
||||
pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
|
||||
Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None }
|
||||
}
|
||||
|
||||
/// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is
|
||||
/// larger than that and decodes; otherwise unchanged.
|
||||
pub fn with_proxy(mut self) -> Self {
|
||||
if self.proxy.is_none() {
|
||||
self.proxy = proxy_png(&self.bytes).map(Arc::new);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Are the bytes the preview (the linked file hasn't been read)?
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
|
||||
/// undecodable.
|
||||
fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if w.max(h) <= PROXY_SIZE {
|
||||
return None;
|
||||
}
|
||||
let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
let mut png = Vec::new();
|
||||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
/// `n` and every descendant, opacity-mask art included.
|
||||
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &n.mask {
|
||||
walk_all(&m.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_all(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
|
||||
/// opacity-mask art included.
|
||||
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
|
||||
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
|
||||
for root in roots {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
f(n, im);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of image blobs only linked images show: what a save writes as their preview.
|
||||
pub fn linked_only_images(&self) -> BTreeSet<String> {
|
||||
if self.images.values().all(|b| b.proxy.is_none()) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
// Per key: (shown linked, shown embedded).
|
||||
let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
|
||||
self.visit_images(|_, im| {
|
||||
let u = uses.entry(im.key.as_str()).or_default();
|
||||
if im.link.is_some() {
|
||||
u.0 = true;
|
||||
} else {
|
||||
u.1 = true;
|
||||
}
|
||||
});
|
||||
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
|
||||
/// the art it replaces.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlacementOptions {
|
||||
#[serde(default)]
|
||||
pub preserve: Preserve,
|
||||
/// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]).
|
||||
#[serde(default)]
|
||||
pub align: Align,
|
||||
/// Clip the new art to the old bounds where it is larger.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub clip: bool,
|
||||
}
|
||||
|
||||
/// What a relinked image keeps of the art it replaces.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Preserve {
|
||||
/// Its scale (relative to each file's 100% size), rotation and position.
|
||||
Transforms,
|
||||
/// Its bounds: the new art is stretched into them.
|
||||
#[default]
|
||||
Bounds,
|
||||
/// Nothing but its position: the new art at 100%, unrotated.
|
||||
FileDimensions,
|
||||
/// Its bounds: the new art scaled proportionally to fit inside them.
|
||||
Fit,
|
||||
/// Its bounds: the new art scaled proportionally to fill them.
|
||||
Fill,
|
||||
}
|
||||
|
||||
impl Preserve {
|
||||
pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill];
|
||||
|
||||
/// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Preserve::Transforms => "transforms",
|
||||
Preserve::Bounds => "bounds",
|
||||
Preserve::FileDimensions => "fileDimensions",
|
||||
Preserve::Fit => "fit",
|
||||
Preserve::Fill => "fill",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of the 3×3 alignment grid, row by row from the top left.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
TopLeft,
|
||||
Top,
|
||||
TopRight,
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
BottomLeft,
|
||||
Bottom,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
pub const ALL: [Align; 9] =
|
||||
[Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight];
|
||||
|
||||
/// The id commands use (`topLeft` … `bottomRight`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Align::TopLeft => "topLeft",
|
||||
Align::Top => "top",
|
||||
Align::TopRight => "topRight",
|
||||
Align::Left => "left",
|
||||
Align::Center => "center",
|
||||
Align::Right => "right",
|
||||
Align::BottomLeft => "bottomLeft",
|
||||
Align::Bottom => "bottom",
|
||||
Align::BottomRight => "bottomRight",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|a| a.id() == id)
|
||||
}
|
||||
|
||||
/// The fractions (0, ½ or 1) of the free room left of and above the new art.
|
||||
pub fn fractions(self) -> (f64, f64) {
|
||||
let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4);
|
||||
((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
|
||||
/// art included) for which `pick` holds; only the subtrees holding one are copied.
|
||||
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
|
||||
fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool {
|
||||
let mut hit = false;
|
||||
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im)));
|
||||
hit
|
||||
}
|
||||
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) {
|
||||
if !holds(n, pick) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let NodeKind::Image(im) = &mut n.kind
|
||||
&& pick(im)
|
||||
{
|
||||
f(im);
|
||||
}
|
||||
if let Some(m) = &mut n.mask {
|
||||
update(&mut m.art, pick, f);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
update(c, pick, f);
|
||||
}
|
||||
}
|
||||
let roots =
|
||||
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||||
for root in roots {
|
||||
update(root, &pick, &mut f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NodeId;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
|
||||
let id = d.alloc_id();
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(
|
||||
Some(layer),
|
||||
0,
|
||||
Node::new(
|
||||
id,
|
||||
NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_string_from_older_files_reads_as_a_link() {
|
||||
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
|
||||
let link = old.link.unwrap();
|
||||
assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None));
|
||||
let new: ImageObject =
|
||||
serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap();
|
||||
assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") }));
|
||||
let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap();
|
||||
assert_eq!(none.link, None);
|
||||
// Written as an object, with only what is known.
|
||||
let v = serde_json::to_value(&new).unwrap();
|
||||
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_get_a_small_png_preview() {
|
||||
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
|
||||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (256, 128));
|
||||
assert!(!big.is_proxy(), "the bytes are the full image");
|
||||
assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels");
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_blobs_no_embedded_image_shows_are_linked_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
for k in ["a", "b", "c"] {
|
||||
d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy());
|
||||
}
|
||||
image(&mut d, "a", Some(LinkInfo::new("/a.png")));
|
||||
image(&mut d, "b", Some(LinkInfo::new("/b.png")));
|
||||
image(&mut d, "b", None);
|
||||
image(&mut d, "c", None);
|
||||
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Printer's marks drawn as art: the trim marks of Object → Create Trim Marks and Effect → Crop
|
||||
//! Marks, stroked in the Registration colour so that they print on every plate, and the printer's
|
||||
//! marks around a printed page ([`PrinterMarks`]: PDF export's Marks and Bleeds, and print).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use crate::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
|
||||
/// Trim mark style (the preference `japaneseCropMarks` picks Japanese).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MarkStyle {
|
||||
/// One line per edge at each corner, set off from the trim box.
|
||||
#[default]
|
||||
Roman,
|
||||
/// Double lines at each corner (the trim and the bleed edges) and centre marks on each side.
|
||||
Japanese,
|
||||
}
|
||||
|
||||
impl MarkStyle {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
MarkStyle::Roman => "roman",
|
||||
MarkStyle::Japanese => "japanese",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an id, ignoring case.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[MarkStyle::Roman, MarkStyle::Japanese].into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim marks around a rectangle (the trim box).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TrimMarks {
|
||||
pub style: MarkStyle,
|
||||
/// Roman: the gap between the trim box and the marks.
|
||||
pub offset: f64,
|
||||
/// How long each mark is.
|
||||
pub length: f64,
|
||||
/// Japanese: the bleed, between the trim lines and the outer lines (3 mm).
|
||||
pub bleed: f64,
|
||||
/// Stroke weight.
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
impl Default for TrimMarks {
|
||||
fn default() -> Self {
|
||||
Self { style: MarkStyle::Roman, offset: 9.0, length: 18.0, bleed: 3.0 * 72.0 / 25.4, weight: 0.3 }
|
||||
}
|
||||
}
|
||||
|
||||
impl TrimMarks {
|
||||
/// The default marks of `style`.
|
||||
pub fn of(style: MarkStyle) -> Self {
|
||||
Self { style, ..Default::default() }
|
||||
}
|
||||
|
||||
/// How far the marks reach outside the trim box.
|
||||
pub fn reach(&self) -> f64 {
|
||||
let gap = match self.style {
|
||||
MarkStyle::Roman => self.offset,
|
||||
MarkStyle::Japanese => self.bleed,
|
||||
};
|
||||
gap + self.length + self.weight
|
||||
}
|
||||
|
||||
/// The marks' lines around `r`.
|
||||
pub fn lines(&self, r: Rect) -> Vec<(Point, Point)> {
|
||||
let (l, mut out) = (self.length, vec![]);
|
||||
// Each corner with its outward directions.
|
||||
let corners = [(r.x0, r.y0, -1.0, -1.0), (r.x1, r.y0, 1.0, -1.0), (r.x1, r.y1, 1.0, 1.0), (r.x0, r.y1, -1.0, 1.0)];
|
||||
match self.style {
|
||||
MarkStyle::Roman => {
|
||||
let o = self.offset;
|
||||
for (x, y, sx, sy) in corners {
|
||||
out.push((Point::new(x + sx * o, y), Point::new(x + sx * (o + l), y)));
|
||||
out.push((Point::new(x, y + sy * o), Point::new(x, y + sy * (o + l))));
|
||||
}
|
||||
}
|
||||
MarkStyle::Japanese => {
|
||||
let b = self.bleed;
|
||||
for (x, y, sx, sy) in corners {
|
||||
// The trim lines, outside the bleed, and the bleed lines meeting at its corner.
|
||||
out.push((Point::new(x + sx * b, y), Point::new(x + sx * (b + l), y)));
|
||||
out.push((Point::new(x, y + sy * b), Point::new(x, y + sy * (b + l))));
|
||||
out.push((Point::new(x, y + sy * b), Point::new(x + sx * (b + l), y + sy * b)));
|
||||
out.push((Point::new(x + sx * b, y), Point::new(x + sx * b, y + sy * (b + l))));
|
||||
}
|
||||
// A cross outside the bleed at the middle of each side.
|
||||
let c = r.center();
|
||||
for (p, dx, dy) in [
|
||||
(Point::new(c.x, r.y0), 0.0, -1.0),
|
||||
(Point::new(r.x1, c.y), 1.0, 0.0),
|
||||
(Point::new(c.x, r.y1), 0.0, 1.0),
|
||||
(Point::new(r.x0, c.y), -1.0, 0.0),
|
||||
] {
|
||||
let at = |d: f64| Point::new(p.x + dx * d, p.y + dy * d);
|
||||
out.push((at(b), at(b + l)));
|
||||
let m = at(b + l / 2.0);
|
||||
out.push((Point::new(m.x - dy * l / 2.0, m.y - dx * l / 2.0), Point::new(m.x + dy * l / 2.0, m.y + dx * l / 2.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The marks around `r` as a group named "Trim Marks" of lines stroked in Registration, with
|
||||
/// ids from `alloc` (the group's last).
|
||||
pub fn group(&self, r: Rect, alloc: &mut dyn FnMut() -> NodeId) -> Node {
|
||||
let ap = Appearance::basic(Paint::None, Paint::registration(), self.weight);
|
||||
let children = self.lines(r).into_iter().map(|(a, b)| Arc::new(Node::path(alloc(), shapes::line(a, b), ap.clone()))).collect();
|
||||
let mut g = Node::group(alloc(), children);
|
||||
g.name = Some("Trim Marks".into());
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` grown by `[top, bottom, left, right]` (a bleed, or how far marks reach).
|
||||
pub fn outset(r: Rect, [top, bottom, left, right]: [f64; 4]) -> Rect {
|
||||
Rect::new(r.x0 - left, r.y0 - top, r.x1 + right, r.y1 + bottom)
|
||||
}
|
||||
|
||||
/// The printer's marks around a printed page's trim box (its artboard): trim marks, a
|
||||
/// registration target at the middle of each side, colour bars along the top and the page
|
||||
/// information line below the bottom marks. They sit in a band [`PrinterMarks::LENGTH`] deep that
|
||||
/// starts at the offset from the trim box (outside the bleed), and are drawn in Registration, the
|
||||
/// colour bars aside.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PrinterMarks {
|
||||
pub trim: bool,
|
||||
pub registration: bool,
|
||||
pub color_bars: bool,
|
||||
pub page_info: bool,
|
||||
pub style: MarkStyle,
|
||||
/// Stroke weight of the trim marks and registration targets.
|
||||
pub weight: f64,
|
||||
/// Roman: the gap between the trim box and the marks (at least the bleed).
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
/// Where the printer's marks of one page go ([`PrinterMarks::layout`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MarksLayout {
|
||||
/// Lines stroked in Registration: the trim marks and the targets' cross hairs.
|
||||
pub lines: Vec<(Point, Point)>,
|
||||
/// Registration target rings (centre, radius), stroked in Registration.
|
||||
pub rings: Vec<(Point, f64)>,
|
||||
/// The colour bars: each patch and its paint.
|
||||
pub patches: Vec<(Rect, Paint)>,
|
||||
/// The page information line's baseline start.
|
||||
pub info: Option<Point>,
|
||||
}
|
||||
|
||||
impl PrinterMarks {
|
||||
/// How deep the band of marks is (the trim marks' length).
|
||||
pub const LENGTH: f64 = 18.0;
|
||||
/// The side of a colour bar patch.
|
||||
pub const PATCH: f64 = 10.0;
|
||||
/// The page information's type size.
|
||||
pub const INFO_SIZE: f64 = 7.0;
|
||||
|
||||
/// Any mark is on.
|
||||
pub fn any(&self) -> bool {
|
||||
self.trim || self.registration || self.color_bars || self.page_info
|
||||
}
|
||||
|
||||
/// The trim marks for a page with `bleed` (`[top, bottom, left, right]`): Roman ones outside
|
||||
/// the bleed, Japanese ones with their outer lines on the bleed (3 mm without one).
|
||||
pub fn trim_marks(&self, bleed: [f64; 4]) -> TrimMarks {
|
||||
let most = bleed.iter().copied().fold(0.0, f64::max);
|
||||
let d = TrimMarks::of(self.style);
|
||||
TrimMarks { offset: self.offset.max(most), length: Self::LENGTH, bleed: if most > 0.0 { most } else { d.bleed }, weight: self.weight, ..d }
|
||||
}
|
||||
|
||||
/// The distance from the trim box to the band of marks.
|
||||
fn gap(&self, bleed: [f64; 4]) -> f64 {
|
||||
let t = self.trim_marks(bleed);
|
||||
match self.style {
|
||||
MarkStyle::Roman => t.offset,
|
||||
MarkStyle::Japanese => t.bleed,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the marks reach outside the trim box, `[top, bottom, left, right]` (0 without
|
||||
/// marks): the band on every side, and the page information below it.
|
||||
pub fn reach(&self, bleed: [f64; 4]) -> [f64; 4] {
|
||||
if !self.any() {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let band = self.gap(bleed) + Self::LENGTH + self.weight;
|
||||
let bottom = if self.page_info { band + Self::INFO_SIZE * 1.6 } else { band };
|
||||
[band, bottom, band, band]
|
||||
}
|
||||
|
||||
/// Where the marks around trim box `trim` go, for a page with `bleed` and the spot inks
|
||||
/// `spots` (their colour bar patches follow the process ones).
|
||||
pub fn layout(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint]) -> MarksLayout {
|
||||
let mut out = MarksLayout::default();
|
||||
if !self.any() {
|
||||
return out;
|
||||
}
|
||||
let (gap, l) = (self.gap(bleed), Self::LENGTH);
|
||||
if self.trim {
|
||||
out.lines = self.trim_marks(bleed).lines(trim);
|
||||
}
|
||||
let c = trim.center();
|
||||
// The middle of the band.
|
||||
let mid = gap + l / 2.0;
|
||||
if self.registration {
|
||||
let sides = [
|
||||
(Point::new(c.x, trim.y0), 0.0, -1.0),
|
||||
(Point::new(trim.x1, c.y), 1.0, 0.0),
|
||||
(Point::new(c.x, trim.y1), 0.0, 1.0),
|
||||
(Point::new(trim.x0, c.y), -1.0, 0.0),
|
||||
];
|
||||
for (p, dx, dy) in sides {
|
||||
let at = Point::new(p.x + dx * mid, p.y + dy * mid);
|
||||
out.rings.push((at, l / 3.0));
|
||||
out.rings.push((at, l / 6.0));
|
||||
// Japanese trim marks draw this cross already.
|
||||
if !(self.trim && self.style == MarkStyle::Japanese) {
|
||||
out.lines.push((Point::new(at.x - l / 2.0, at.y), Point::new(at.x + l / 2.0, at.y)));
|
||||
out.lines.push((Point::new(at.x, at.y - l / 2.0), Point::new(at.x, at.y + l / 2.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.color_bars {
|
||||
let s = Self::PATCH;
|
||||
let y = trim.y0 - mid - s / 2.0;
|
||||
let cmyk = |c, m, y, k| Paint::solid(Color::cmyk(c, m, y, k));
|
||||
// Solid inks, their overprints and the spot inks left of the top target; black tints
|
||||
// right of it. Patches that don't fit are left out.
|
||||
let solids = [
|
||||
cmyk(1.0, 0.0, 0.0, 0.0),
|
||||
cmyk(0.0, 1.0, 0.0, 0.0),
|
||||
cmyk(0.0, 0.0, 1.0, 0.0),
|
||||
cmyk(0.0, 0.0, 0.0, 1.0),
|
||||
cmyk(1.0, 1.0, 0.0, 0.0),
|
||||
cmyk(1.0, 0.0, 1.0, 0.0),
|
||||
cmyk(0.0, 1.0, 1.0, 0.0),
|
||||
cmyk(1.0, 1.0, 1.0, 0.0),
|
||||
];
|
||||
let tints: Vec<Paint> = (1..=10).map(|i| cmyk(0.0, 0.0, 0.0, i as f32 / 10.0)).collect();
|
||||
let clear = l / 2.0 + 2.0;
|
||||
let rows = [(solids.iter().chain(spots).cloned().collect(), trim.x0 + s / 2.0, c.x - clear), (tints, c.x + clear, trim.x1 - s / 2.0)];
|
||||
for (paints, from, to) in rows {
|
||||
let fits = ((to - from) / s).floor().max(0.0) as usize;
|
||||
for (i, paint) in paints.into_iter().take(fits).enumerate() {
|
||||
let x = from + i as f64 * s;
|
||||
out.patches.push((Rect::new(x, y, x + s, y + s), paint));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.page_info {
|
||||
out.info = Some(Point::new(trim.x0, trim.y1 + gap + l + self.weight + Self::INFO_SIZE * 1.1));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The marks around `trim` as a group named "Printer's Marks", with ids from `alloc` (the
|
||||
/// group's last): the colour bar patches, then Registration lines and rings, then `info` as
|
||||
/// point type in Registration.
|
||||
pub fn art(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint], info: &str, alloc: &mut dyn FnMut() -> NodeId) -> Node {
|
||||
let m = self.layout(trim, bleed, spots);
|
||||
let reg = Appearance::basic(Paint::None, Paint::registration(), self.weight);
|
||||
let shape = |id, path, ap: &Appearance| Arc::new(Node::path(id, path, ap.clone()));
|
||||
let mut children: Vec<Arc<Node>> =
|
||||
m.patches.into_iter().map(|(r, p)| shape(alloc(), shapes::rectangle(r), &Appearance::basic(p, Paint::None, 0.0))).collect();
|
||||
children.extend(m.lines.into_iter().map(|(a, b)| shape(alloc(), shapes::line(a, b), ®)));
|
||||
children.extend(m.rings.into_iter().map(|(c, r)| shape(alloc(), shapes::ellipse(Rect::from_center_size(c, (2.0 * r, 2.0 * r))), ®)));
|
||||
if let Some(at) = m.info.filter(|_| !info.is_empty()) {
|
||||
let style = CharStyle { size: Self::INFO_SIZE, fill: Paint::registration(), ..Default::default() };
|
||||
children.push(Arc::new(Node::new(alloc(), NodeKind::Text(Box::new(TextObject::point(at, info, style))))));
|
||||
}
|
||||
let mut g = Node::group(alloc(), children);
|
||||
g.name = Some("Printer's Marks".into());
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roman_marks_sit_off_the_corners_and_japanese_ones_are_doubled() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let roman = TrimMarks::default().lines(r);
|
||||
assert_eq!(roman.len(), 8);
|
||||
assert!(roman.iter().all(|(a, b)| !r.contains(*a) && !r.contains(*b)));
|
||||
assert_eq!(roman[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0)));
|
||||
let jp = TrimMarks::of(MarkStyle::Japanese);
|
||||
let lines = jp.lines(r);
|
||||
assert_eq!(lines.len(), 24, "four lines per corner and a cross per side");
|
||||
// Two horizontal lines at the top-left corner: the trim line and the bleed line.
|
||||
let tl: Vec<f64> = lines[..4].iter().filter(|(a, b)| a.y == b.y).map(|(a, _)| a.y).collect();
|
||||
assert_eq!(tl.len(), 2);
|
||||
assert!((tl[0] - 0.0).abs() < 1e-9 && (tl[1] + jp.bleed).abs() < 1e-9, "{tl:?}");
|
||||
let mut n = 0;
|
||||
let g = jp.group(r, &mut || {
|
||||
n += 1;
|
||||
NodeId(n)
|
||||
});
|
||||
assert_eq!((g.children().unwrap().len(), g.id), (24, NodeId(25)));
|
||||
assert!(g.children().unwrap()[0].appearance.stroke().unwrap().paint.is_registration());
|
||||
assert_eq!(MarkStyle::parse("Japanese"), Some(MarkStyle::Japanese));
|
||||
}
|
||||
|
||||
fn all(style: MarkStyle) -> PrinterMarks {
|
||||
PrinterMarks { trim: true, registration: true, color_bars: true, page_info: true, style, weight: 0.25, offset: 6.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_marks_sit_in_a_band_outside_the_trim_box() {
|
||||
let trim = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let m = all(MarkStyle::Roman);
|
||||
let lay = m.layout(trim, [0.0; 4], &[]);
|
||||
// Golden positions: the band starts at the 6 pt offset and is 18 pt deep.
|
||||
assert_eq!(lay.lines[0], (Point::new(-6.0, 0.0), Point::new(-24.0, 0.0)), "top-left trim mark");
|
||||
assert_eq!(lay.rings[..2], [(Point::new(100.0, -15.0), 6.0), (Point::new(100.0, -15.0), 3.0)], "top target");
|
||||
assert_eq!(lay.lines[8..10], [(Point::new(91.0, -15.0), Point::new(109.0, -15.0)), (Point::new(100.0, -24.0), Point::new(100.0, -6.0))]);
|
||||
assert_eq!(lay.patches[0], (Rect::new(5.0, -20.0, 15.0, -10.0), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))), "cyan first");
|
||||
assert_eq!(lay.patches[8].0, Rect::new(111.0, -20.0, 121.0, -10.0), "black tints right of the target");
|
||||
assert_eq!(lay.patches.len(), 8 + 8, "as many patches as fit each half");
|
||||
assert_eq!(lay.info, Some(Point::new(0.0, 100.0 + 24.25 + 7.7)));
|
||||
let reach = m.reach([0.0; 4]);
|
||||
assert_eq!(reach, [24.25, 24.25 + 11.2, 24.25, 24.25]);
|
||||
// Everything lies outside the trim box and inside the sheet the reach makes.
|
||||
let sheet = outset(trim, reach);
|
||||
let points = lay.lines.iter().flat_map(|(a, b)| [*a, *b]).chain(lay.rings.iter().map(|(c, _)| *c));
|
||||
for p in points.chain(lay.patches.iter().flat_map(|(r, _)| [r.origin(), Point::new(r.x1, r.y1)])) {
|
||||
assert!(sheet.contains(p) && !trim.inflate(-1e-9, -1e-9).contains(p), "{p:?}");
|
||||
}
|
||||
// A bleed pushes the marks out; no marks reach nothing.
|
||||
assert_eq!(m.layout(trim, [9.0, 0.0, 0.0, 0.0], &[]).lines[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0)));
|
||||
assert_eq!(PrinterMarks { trim: false, registration: false, color_bars: false, page_info: false, ..m }.reach([9.0; 4]), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_printer_marks_share_the_centre_crosses_and_draw_as_art() {
|
||||
let trim = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let m = all(MarkStyle::Japanese);
|
||||
let lay = m.layout(trim, [0.0; 4], &[]);
|
||||
assert_eq!(lay.lines.len(), 24, "the targets add rings to the trim marks' centre crosses");
|
||||
let b = TrimMarks::default().bleed;
|
||||
assert_eq!(lay.rings[0].0, Point::new(100.0, -(b + 9.0)));
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let wide = Rect::new(0.0, 0.0, 400.0, 100.0);
|
||||
assert_eq!(m.layout(wide, [0.0; 4], std::slice::from_ref(&spot)).patches[8].1, spot, "spot inks follow the process patches");
|
||||
let mut n = 0;
|
||||
let g = m.art(trim, [0.0; 4], &[], "Page 1", &mut || {
|
||||
n += 1;
|
||||
NodeId(n)
|
||||
});
|
||||
let kids = g.children().unwrap();
|
||||
let NodeKind::Text(t) = &kids.last().unwrap().kind else { panic!("the page information is type") };
|
||||
assert_eq!((t.plain_text().as_str(), t.first_style().fill.is_registration()), ("Page 1", true));
|
||||
let strokes: Vec<_> = kids.iter().filter_map(|k| k.appearance.stroke()).filter(|s| !s.paint.is_none()).collect();
|
||||
assert!(strokes.len() == 24 + 8 && strokes.iter().all(|s| s.paint.is_registration()), "lines and rings in Registration");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
|
||||
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
|
||||
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
|
||||
//! before File Info existed load unchanged.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File Info → Copyright Status.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CopyrightStatus {
|
||||
#[default]
|
||||
Unknown,
|
||||
Copyrighted,
|
||||
PublicDomain,
|
||||
}
|
||||
|
||||
impl CopyrightStatus {
|
||||
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "unknown",
|
||||
CopyrightStatus::Copyrighted => "copyrighted",
|
||||
CopyrightStatus::PublicDomain => "publicDomain",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "Unknown",
|
||||
CopyrightStatus::Copyrighted => "Copyrighted",
|
||||
CopyrightStatus::PublicDomain => "Public Domain",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s) || c.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest File Info rating (stars).
|
||||
pub const MAX_RATING: u8 = 5;
|
||||
|
||||
/// File → File Info.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DocMetadata {
|
||||
pub author: String,
|
||||
/// The author's position or job title.
|
||||
pub author_title: String,
|
||||
pub description: String,
|
||||
/// Keywords, each once, in the order they were added.
|
||||
pub keywords: Vec<String>,
|
||||
/// 0 (unrated) to [`MAX_RATING`] stars.
|
||||
pub rating: u8,
|
||||
pub copyright_status: CopyrightStatus,
|
||||
pub copyright_notice: String,
|
||||
/// Where to read more about the copyright.
|
||||
pub copyright_url: String,
|
||||
/// When the document was created, as Unix seconds (UTC).
|
||||
pub created: Option<i64>,
|
||||
/// When the document was last saved, as Unix seconds (UTC).
|
||||
pub modified: Option<i64>,
|
||||
}
|
||||
|
||||
impl DocMetadata {
|
||||
/// The PNG text entries (registered keywords where one fits) for a document titled `title`:
|
||||
/// only the non-empty ones.
|
||||
pub fn png_text<'a>(&'a self, title: &'a str) -> Vec<(&'static str, std::borrow::Cow<'a, str>)> {
|
||||
let mut out: Vec<(&'static str, std::borrow::Cow<'a, str>)> = vec![];
|
||||
let mut push = |k: &'static str, v: std::borrow::Cow<'a, str>| {
|
||||
if !v.trim().is_empty() {
|
||||
out.push((k, v));
|
||||
}
|
||||
};
|
||||
push("Title", title.into());
|
||||
push("Author", self.author.as_str().into());
|
||||
push("Description", self.description.as_str().into());
|
||||
push("Keywords", self.keywords.join(", ").into());
|
||||
push("Copyright", self.copyright_notice.as_str().into());
|
||||
push("Copyright URL", self.copyright_url.as_str().into());
|
||||
push("Creation Time", self.created.map(iso8601).unwrap_or_default().into());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
|
||||
/// Gregorian; years clamped to 0–9999).
|
||||
pub fn civil(t: i64) -> [i64; 6] {
|
||||
// Clamp first so every step stays in range for any input.
|
||||
let t = t.clamp(-62_167_219_200, 253_402_300_799);
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
|
||||
}
|
||||
|
||||
/// Unix seconds → `2026-10-05T14:03:00Z`.
|
||||
pub fn iso8601(t: i64) -> String {
|
||||
let [y, mo, d, h, mi, s] = civil(t);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dates_and_statuses() {
|
||||
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
|
||||
assert_eq!(civil(i64::MIN)[0], 0);
|
||||
assert_eq!(civil(i64::MAX)[0], 9999);
|
||||
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("maybe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_text_skips_empty_entries() {
|
||||
let m = DocMetadata { author: "Ada".into(), keywords: vec!["a".into(), "b".into()], created: Some(0), ..Default::default() };
|
||||
let t: Vec<(&str, String)> = m.png_text("Poster").into_iter().map(|(k, v)| (k, v.into_owned())).collect();
|
||||
assert_eq!(
|
||||
t,
|
||||
[
|
||||
("Title", "Poster".to_string()),
|
||||
("Author", "Ada".into()),
|
||||
("Keywords", "a, b".into()),
|
||||
("Creation Time", "1970-01-01T00:00:00Z".into())
|
||||
]
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`])
|
||||
//! through the transforms applied to it, so after a rotation its bounding box and handles stay
|
||||
//! square to it: scaling by a side handle stretches it along its own width or height. Path
|
||||
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// Angles closer than this (degrees) are the same.
|
||||
const EPS_DEG: f64 = 1e-6;
|
||||
|
||||
/// A bounding box square to a frame turned by `angle`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OrientedBox {
|
||||
/// Counter-clockwise degrees in (-180, 180]; 0: square to the page.
|
||||
pub angle: f64,
|
||||
/// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page.
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
impl OrientedBox {
|
||||
/// A box square to the page.
|
||||
pub fn aligned(rect: Rect) -> Self {
|
||||
Self { angle: 0.0, rect }
|
||||
}
|
||||
/// The turned frame → the page (a rotation about the page origin).
|
||||
pub fn to_doc(&self) -> Affine {
|
||||
if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) }
|
||||
}
|
||||
/// A page point in the turned frame.
|
||||
pub fn to_local(&self, p: Point) -> Point {
|
||||
self.to_doc().inverse() * p
|
||||
}
|
||||
/// `a`, a transform of the turned frame, as a page transform.
|
||||
pub fn conjugate(&self, a: Affine) -> Affine {
|
||||
if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() }
|
||||
}
|
||||
pub fn center(&self) -> Point {
|
||||
self.to_doc() * self.rect.center()
|
||||
}
|
||||
/// One of the 9 reference points (row-major from the box's own top-left), on the page.
|
||||
pub fn reference_point(&self, index: usize) -> Point {
|
||||
self.to_doc() * vectorcraft_geom::reference_point(self.rect, index)
|
||||
}
|
||||
/// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
let r = self.rect;
|
||||
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis.
|
||||
/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller
|
||||
/// angle) is kept, so flipping an upright object keeps it upright.
|
||||
pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) {
|
||||
return angle;
|
||||
}
|
||||
let (s, c) = (-angle.to_radians()).sin_cos();
|
||||
let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s);
|
||||
// Degenerate (or non-finite) transforms leave the angle alone.
|
||||
let len = v.hypot();
|
||||
if !len.is_finite() || len <= 1e-12 {
|
||||
return angle;
|
||||
}
|
||||
let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees());
|
||||
let dist = |x: f64| normalize_deg(x - angle).abs();
|
||||
if a.determinant() < 0.0 {
|
||||
let alt = normalize_deg(out + 180.0);
|
||||
let (d0, d1) = (dist(out), dist(alt));
|
||||
if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) {
|
||||
out = alt;
|
||||
}
|
||||
}
|
||||
// Moves and scales along the object's own axes keep its angle exactly.
|
||||
if dist(out) <= EPS_DEG {
|
||||
angle
|
||||
} else if out.abs() <= EPS_DEG {
|
||||
0.0
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it.
|
||||
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
|
||||
// The group's own strokes paint around its members (as in `visual_bounds`).
|
||||
let o = if visual { self.appearance.outset() } else { 0.0 };
|
||||
b.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first()?;
|
||||
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
|
||||
clip.bounds_in(a, visual && stroked)
|
||||
}
|
||||
_ => {
|
||||
let mut n = self.clone();
|
||||
n.transform(a, false);
|
||||
if visual { n.visual_bounds() } else { n.geometric_bounds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0.
|
||||
pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 {
|
||||
let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle);
|
||||
let Some(first) = angles.next() else { return 0.0 };
|
||||
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
|
||||
}
|
||||
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
|
||||
/// geometric bounds, or `visual` with their strokes.
|
||||
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
|
||||
let angle = self.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Appearance;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn rot(deg: f64) -> Affine {
|
||||
Affine::rotate(-deg.to_radians())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_add_and_moves_keep_the_angle() {
|
||||
assert!(close(transformed_angle(0.0, rot(45.0)), 45.0));
|
||||
assert!(close(transformed_angle(170.0, rot(20.0)), -170.0));
|
||||
assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0);
|
||||
assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scales_keep_the_angle_exactly() {
|
||||
let b = OrientedBox { angle: 45.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0);
|
||||
// A flip along the object's own axis keeps the box too.
|
||||
let b = OrientedBox { angle: 135.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_flips_mirror_the_angle() {
|
||||
let flip_h = Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let flip_v = Affine::scale_non_uniform(1.0, -1.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_h), 0.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_v), 0.0);
|
||||
assert!(close(transformed_angle(30.0, flip_h), -30.0));
|
||||
assert!(close(transformed_angle(30.0, flip_v), -30.0));
|
||||
// Degenerate transforms leave it alone.
|
||||
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_rectangle() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let c = Point::new(50.0, 25.0);
|
||||
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
|
||||
let b = d.oriented_bounds(&[id], false).unwrap();
|
||||
assert!(close(b.angle, 30.0));
|
||||
assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0));
|
||||
assert!(b.center().distance(c) < 1e-9);
|
||||
// The page box is bigger.
|
||||
assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0);
|
||||
// Grouped: the group has no angle of its own, the bounds are square to the page.
|
||||
let g = d.alloc_id();
|
||||
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
|
||||
d.move_node(id, Some(g), 0).unwrap();
|
||||
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Overprinting: each fill and stroke (and each character's fill and stroke) can print its inks
|
||||
//! over the inks below instead of knocking them out ([`crate::FillLayer::overprint`]).
|
||||
//! Edit → Edit Colors → Overprint Black sets it on black fills and strokes ([`OverprintBlack`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// `Document.unknown` key of files from before per-fill/stroke overprint: the ids of objects
|
||||
/// marked Overprint Black ([`Document::migrate_overprint_black`]).
|
||||
pub const LEGACY_OVERPRINT_KEY: &str = "overprintBlack";
|
||||
|
||||
/// Black ink of at least `min_k` (0..1): CMYK with that much K and, unless `rich`, no cyan,
|
||||
/// magenta or yellow; or a grey that dark. RGB and Lab colours are never black ink.
|
||||
pub fn is_black_ink(c: &Color, min_k: f32, rich: bool) -> bool {
|
||||
const E: f32 = 0.005;
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => k >= min_k - E && (rich || (c <= E && m <= E && y <= E)),
|
||||
Color::Gray { k } => k >= min_k - E,
|
||||
Color::Rgb { .. } | Color::Lab { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit → Edit Colors → Overprint Black: which fills and strokes count as black, and whether
|
||||
/// they start or stop overprinting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OverprintBlack {
|
||||
/// Remove overprinting instead of adding it.
|
||||
pub remove: bool,
|
||||
/// Minimum black ink, 0..1.
|
||||
pub min_k: f32,
|
||||
pub fill: bool,
|
||||
pub stroke: bool,
|
||||
/// Include Blacks with CMY: rich blacks count too.
|
||||
pub rich: bool,
|
||||
/// Include Spot Blacks: colours linked to a spot swatch count too.
|
||||
pub spot: bool,
|
||||
}
|
||||
|
||||
impl Default for OverprintBlack {
|
||||
fn default() -> Self {
|
||||
Self { remove: false, min_k: 1.0, fill: true, stroke: true, rich: false, spot: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl OverprintBlack {
|
||||
/// Whether `p` is black: a solid colour (linked to one of the spot swatches `spots` only with
|
||||
/// [`Self::spot`]), or a gradient whose every stop is.
|
||||
fn black(&self, p: &Paint, spots: &[String]) -> bool {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
(self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich)
|
||||
}
|
||||
Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) the overprint of `n`'s own black fills and strokes and its characters'.
|
||||
/// `spots`: the document's spot swatch names ([`Document::spot_names`]). Returns whether
|
||||
/// anything changed.
|
||||
pub fn apply(&self, n: &mut Node, spots: &[String]) -> bool {
|
||||
let on = !self.remove;
|
||||
let mut changed = false;
|
||||
let mut set = |flag: &mut bool| {
|
||||
changed |= *flag != on;
|
||||
*flag = on;
|
||||
};
|
||||
for it in &mut n.appearance.items {
|
||||
if (if it.is_fill() { self.fill } else { self.stroke }) && self.black(it.paint(), spots) {
|
||||
set(it.overprint_mut());
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
if self.fill && self.black(&r.style.fill, spots) {
|
||||
set(&mut r.style.overprint_fill);
|
||||
}
|
||||
if self.stroke && self.black(&r.style.stroke, spots) {
|
||||
set(&mut r.style.overprint_stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Whether any fill or stroke of this subtree overprints (characters' aside).
|
||||
pub fn has_overprint(&self) -> bool {
|
||||
self.appearance.items.iter().any(|i| i.overprint()) || self.children().is_some_and(|c| c.iter().any(|c| c.has_overprint()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (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`
|
||||
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
|
||||
pub fn multiply_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));
|
||||
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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
multiply_overprints(c, discard_white);
|
||||
}
|
||||
}
|
||||
|
||||
/// A solid paint of paper white (no ink in any colour model).
|
||||
pub fn is_white(p: &Paint) -> bool {
|
||||
let Paint::Solid { color, .. } = p else { return false };
|
||||
match *color {
|
||||
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
|
||||
Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0,
|
||||
Color::Gray { k } => k <= 0.0,
|
||||
Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The names of the spot swatches.
|
||||
pub fn spot_names(&self) -> Vec<String> {
|
||||
self.swatches_iter().filter(|s| s.spot).map(|s| s.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Files from before per-fill/stroke overprint listed the objects marked Overprint Black
|
||||
/// under [`LEGACY_OVERPRINT_KEY`]: their 100% black fills and strokes overprint now.
|
||||
pub fn migrate_overprint_black(&mut self) {
|
||||
let Some(list) = self.unknown.remove(LEGACY_OVERPRINT_KEY) else { return };
|
||||
let ids: Vec<NodeId> = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect();
|
||||
// The old list marked spot-linked blacks too (so the spot names don't matter).
|
||||
let op = OverprintBlack { spot: true, ..Default::default() };
|
||||
for id in ids {
|
||||
if let Some(n) = self.node_mut(id) {
|
||||
op.apply(n, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
|
||||
fn overprints_anywhere(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.overprint())
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
|
||||
}
|
||||
|
||||
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
|
||||
/// nodes on the way to them.
|
||||
pub fn clear_overprints(a: &mut Arc<Node>) {
|
||||
if !overprints_anywhere(a) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
for it in &mut n.appearance.items {
|
||||
*it.overprint_mut() = false;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
clear_overprints(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Appearance, CharStyle, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint {
|
||||
Paint::solid(Color::cmyk(c, m, y, k))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_ink_rules() {
|
||||
assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 1.0), 1.0, false));
|
||||
assert!(!is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, false), "rich black needs `rich`");
|
||||
assert!(is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, true));
|
||||
assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.75, false) && !is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.9, false));
|
||||
assert!(is_black_ink(&Color::gray(1.0), 1.0, false));
|
||||
assert!(!is_black_ink(&Color::rgb(0.0, 0.0, 0.0), 1.0, true), "RGB black is not ink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_covers_items_runs_gradients_and_spots() {
|
||||
let k = cmyk(0.0, 0.0, 0.0, 1.0);
|
||||
let mut n = Node::path(NodeId(1), Default::default(), Appearance::basic(k.clone(), k.clone(), 1.0));
|
||||
let op = OverprintBlack { fill: false, ..Default::default() };
|
||||
assert!(op.apply(&mut n, &[]));
|
||||
assert!(!n.appearance.fill().unwrap().overprint && n.appearance.stroke().unwrap().overprint, "fill: false marks strokes only");
|
||||
assert!(!op.apply(&mut n, &[]), "nothing left to change");
|
||||
assert!(OverprintBlack { remove: true, ..Default::default() }.apply(&mut n, &[]));
|
||||
assert!(!n.has_overprint());
|
||||
|
||||
// Gradients count when every stop is black.
|
||||
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
|
||||
for s in &mut g.gradient.stops {
|
||||
s.color = Color::cmyk(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
n.appearance.set_fill(Paint::Gradient(Box::new(g.clone())));
|
||||
assert!(OverprintBlack::default().apply(&mut n, &[]) && n.appearance.fill().unwrap().overprint);
|
||||
g.gradient.stops[0].color = Color::cmyk(0.0, 0.0, 0.0, 0.0);
|
||||
let fill = n.appearance.fill_mut().unwrap();
|
||||
fill.paint = Paint::Gradient(Box::new(g));
|
||||
fill.overprint = false;
|
||||
assert!(!OverprintBlack::default().apply(&mut n, &[]), "the stroke was marked already");
|
||||
assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black");
|
||||
|
||||
// Spot-linked blacks only with `spot`; type runs too.
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() };
|
||||
let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style))));
|
||||
OverprintBlack::default().apply(&mut t, &["Ink".to_string()]);
|
||||
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
|
||||
assert!(!tx.runs[0].style.overprint_fill && tx.runs[0].style.overprint_stroke);
|
||||
OverprintBlack { spot: true, ..Default::default() }.apply(&mut t, &["Ink".to_string()]);
|
||||
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
|
||||
assert!(tx.runs[0].style.overprint_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_overprint_black_list_migrates() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.default_layer().unwrap();
|
||||
let k = cmyk(0.0, 0.0, 0.0, 1.0);
|
||||
let a = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(a, Default::default(), Appearance::basic(k.clone(), cmyk(1.0, 0.0, 0.0, 0.0), 1.0))).unwrap();
|
||||
let b = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(b, Default::default(), Appearance::basic(k, Paint::None, 1.0))).unwrap();
|
||||
d.unknown.insert(LEGACY_OVERPRINT_KEY.into(), serde_json::json!([a.0]));
|
||||
d.migrate_overprint_black();
|
||||
assert!(!d.unknown.contains_key(LEGACY_OVERPRINT_KEY));
|
||||
let na = d.node(a).unwrap();
|
||||
assert!(na.appearance.fill().unwrap().overprint && !na.appearance.stroke().unwrap().overprint, "the black fill, not the cyan stroke");
|
||||
assert!(!d.node(b).unwrap().has_overprint(), "objects not listed keep knocking out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_round_trips_and_old_layers_load() {
|
||||
let mut f = crate::FillLayer::new(Paint::None);
|
||||
assert!(!serde_json::to_string(&f).unwrap().contains("overprint"), "off is not written");
|
||||
f.overprint = true;
|
||||
let back: crate::FillLayer = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
|
||||
assert!(back.overprint);
|
||||
let old: crate::StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
|
||||
assert!(!old.overprint);
|
||||
let st = CharStyle { overprint_stroke: true, ..CharStyle::default() };
|
||||
let back: CharStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
|
||||
assert!(back.overprint_stroke && !back.overprint_fill);
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,10 @@ pub struct PatternDef {
|
||||
pub dim_copies: f32,
|
||||
#[serde(default = "yes")]
|
||||
pub show_tile_edge: bool,
|
||||
/// Show Swatch Bounds: pattern editing mode outlines the part of the tiling the swatch repeats
|
||||
/// (the tile, or the period of a brick or hex tiling), dashed.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub show_swatch_bounds: bool,
|
||||
}
|
||||
|
||||
impl PatternDef {
|
||||
@@ -200,6 +204,7 @@ impl PatternDef {
|
||||
copies: 5,
|
||||
dim_copies: 70.0,
|
||||
show_tile_edge: true,
|
||||
show_swatch_bounds: false,
|
||||
}
|
||||
}
|
||||
pub fn width(&self) -> f64 {
|
||||
@@ -226,6 +231,11 @@ impl PatternDef {
|
||||
pub fn period(&self) -> (f64, f64) {
|
||||
tile_period(self.tile_type, self.width(), self.height())
|
||||
}
|
||||
/// The swatch bounds (Show Swatch Bounds): the period of the tiling from the tile's origin.
|
||||
pub fn swatch_bounds(&self) -> Rect {
|
||||
let (w, h) = self.period();
|
||||
Rect::from_origin_size(self.tile.origin(), (w, h))
|
||||
}
|
||||
/// Maps the art's coordinates into pattern space for instance offset `o`.
|
||||
pub fn instance_xf(&self, o: Vec2) -> Affine {
|
||||
Affine::translate(o - self.tile.origin().to_vec2())
|
||||
@@ -493,9 +503,10 @@ impl RepeatSpec {
|
||||
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
|
||||
}
|
||||
/// Apply a document transform (source + arrangement parameters).
|
||||
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
|
||||
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
|
||||
let scaling = scaling.into();
|
||||
for c in self.source.iter_mut() {
|
||||
Arc::make_mut(c).transform(a, scale_strokes);
|
||||
Arc::make_mut(c).transform(a, scaling);
|
||||
}
|
||||
let s = a.determinant().abs().sqrt();
|
||||
match &mut self.kind {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Perspective Grid attachments (Object › Perspective).
|
||||
//!
|
||||
//! The grid itself is document data (`Document.unknown["perspectiveGrid"]`, modelled by the tools
|
||||
//! crate). Which plane an object lies on is kept on the object, so copies, duplicates and pastes
|
||||
//! stay attached and deleted objects leave nothing stale behind.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Homography, Rect};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
|
||||
/// The plane an object in perspective lies on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PerspectiveAttachment {
|
||||
/// `left`, `right` or `ground`.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub plane: String,
|
||||
/// Where along the plane's normal (points) the parallel plane the object lies on is: 0 is the
|
||||
/// grid plane in its original place.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub depth: f64,
|
||||
/// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art
|
||||
/// in perspective. They stay type and symbol instances; the renderer and exporters project
|
||||
/// their outlines.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projection: Option<[f64; 9]>,
|
||||
/// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not
|
||||
/// saved).
|
||||
#[serde(skip)]
|
||||
pub editing: bool,
|
||||
}
|
||||
|
||||
impl PerspectiveAttachment {
|
||||
pub fn new(plane: &str, depth: f64) -> Self {
|
||||
Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() }
|
||||
}
|
||||
|
||||
/// The projection, when it is a valid one.
|
||||
pub fn homography(&self) -> Option<Homography> {
|
||||
self.projection.and_then(Homography::from_array)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The projective map type or a symbol instance in perspective is drawn through (not while
|
||||
/// Edit Text shows it flat).
|
||||
pub fn projection(&self) -> Option<Homography> {
|
||||
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
|
||||
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
|
||||
}
|
||||
|
||||
/// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective.
|
||||
pub(crate) fn projected(&self, b: Option<Rect>) -> Option<Rect> {
|
||||
match (self.projection(), b) {
|
||||
(Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)),
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
|
||||
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
|
||||
if let Some(p) = self.perspective.as_deref_mut()
|
||||
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
|
||||
{
|
||||
p.projection = Some(h.to_array());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Pixel access for embedded images: a stable content key and recolouring the pixels.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ImageBlob;
|
||||
|
||||
impl ImageBlob {
|
||||
/// A PNG blob of `png` bytes.
|
||||
pub fn png(png: Vec<u8>) -> Self {
|
||||
Self::new("image/png", png)
|
||||
}
|
||||
|
||||
/// A key derived from the bytes (FNV-1a), the same for identical images.
|
||||
pub fn content_key(&self) -> String {
|
||||
crate::links::hash_bytes(&self.bytes)
|
||||
}
|
||||
|
||||
/// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent
|
||||
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
|
||||
/// image can't be decoded or no pixel changed.
|
||||
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
|
||||
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
|
||||
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
|
||||
let mut changed = false;
|
||||
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
|
||||
let rgb = [px.0[0], px.0[1], px.0[2]];
|
||||
let out = *memo.entry(rgb).or_insert_with(|| f(rgb));
|
||||
if out != rgb {
|
||||
px.0[..3].copy_from_slice(&out);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(Self::png(png))
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
|
||||
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
|
||||
/// image, where every pixel is transparent, or when the image can't be decoded. The last image
|
||||
/// decoded is kept for the next sample (the Eyedropper samples one image click after click).
|
||||
pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
sample_pixels(&*self.decoded()?, x, y, size)
|
||||
}
|
||||
|
||||
/// [`Self::sample`] at (`x`, `y`) in the pixel space of an image object of `object` (width,
|
||||
/// height) pixels, which the decoded pixels may not match (a linked image showing its preview).
|
||||
pub fn sample_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
|
||||
let img = self.decoded()?;
|
||||
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
|
||||
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Option<(String, Arc<image::RgbaImage>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
let key = self.content_key();
|
||||
LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, img)) if *k == key => Some(img.clone()),
|
||||
_ => {
|
||||
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
|
||||
*last = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageBlob::sample`] of decoded pixels `img`.
|
||||
fn sample_pixels(img: &image::RgbaImage, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
let (w, h) = (img.width() as i64, img.height() as i64);
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = size.max(1) as i64 / 2;
|
||||
let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0;
|
||||
let a = pa as u64;
|
||||
sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a];
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if alpha == 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |v: u64| ((v + alpha / 2) / alpha) as u8;
|
||||
Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blob(px: &[[u8; 4]], w: u32, h: u32) -> ImageBlob {
|
||||
let img = image::RgbaImage::from_raw(w, h, px.concat()).unwrap();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(png)
|
||||
}
|
||||
|
||||
fn pixels(b: &ImageBlob) -> Vec<[u8; 4]> {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(b.sample(1.5, 0.5, 1), Some([0, 255, 0, 255]));
|
||||
assert_eq!(b.sample(1.5, 0.5, 3), Some([85, 85, 85, 255]), "red, green and blue averaged");
|
||||
assert_eq!(b.sample(3.2, 0.5, 3), Some([0, 0, 255, 128]), "transparent pixels don't count towards the colour");
|
||||
assert_eq!(b.sample(3.2, 0.5, 1), None, "all transparent");
|
||||
assert_eq!(b.sample(-0.5, 0.5, 5), None, "outside");
|
||||
assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_in_the_pixel_space_of_an_object_with_more_pixels() {
|
||||
// An object of 8 × 2 pixels showing these 4 × 1 (a linked image's preview).
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 255]], 4, 1);
|
||||
assert_eq!(b.sample_object(5.0, 1.5, (8, 2), 1), Some([0, 0, 255, 255]));
|
||||
assert_eq!(b.sample_object(1.0, 0.0, (4, 1), 1), b.sample(1.0, 0.0, 1), "the same size: the same pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_each_colour_once_and_keeps_alpha() {
|
||||
let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2);
|
||||
let mut calls = 0;
|
||||
let out = b
|
||||
.map_rgb(|[r, g, bl]| {
|
||||
calls += 1;
|
||||
[255 - r, 255 - g, 255 - bl]
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(calls, 2, "red and blue, once each; the transparent pixel is skipped");
|
||||
assert_eq!(pixels(&out), [[0, 255, 255, 255], [0, 255, 255, 128], [255, 255, 0, 255], [10, 20, 30, 0]]);
|
||||
assert_ne!(out.content_key(), b.content_key());
|
||||
assert_eq!(b.content_key(), blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2).content_key());
|
||||
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
|
||||
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The colour profiles a document is tagged with (Edit → Assign Profile).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// `Document::unknown` key under which files before format v3 kept the assigned profiles
|
||||
/// (`{rgb?, cmyk?}`); still written when saving for those versions.
|
||||
pub const LEGACY_PROFILES_KEY: &str = "colorProfiles";
|
||||
|
||||
/// The profiles a document is tagged with, by name (`None`: the working space).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ColorProfiles {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rgb: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cmyk: Option<String>,
|
||||
}
|
||||
|
||||
impl ColorProfiles {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rgb.is_none() && self.cmyk.is_none()
|
||||
}
|
||||
|
||||
/// The legacy JSON form (`{"rgb": name|null, "cmyk": name|null}`).
|
||||
pub fn to_legacy(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "rgb": self.rgb, "cmyk": self.cmyk })
|
||||
}
|
||||
|
||||
/// Read the legacy JSON form (anything that isn't a name counts as none).
|
||||
pub fn from_legacy(v: &serde_json::Value) -> Self {
|
||||
let name = |k: &str| v.get(k).and_then(serde_json::Value::as_str).map(str::to_string);
|
||||
Self { rgb: name("rgb"), cmyk: name("cmyk") }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Files before format v3 kept the assigned profiles under [`LEGACY_PROFILES_KEY`] in
|
||||
/// `unknown`: move them to [`Document::color_profiles`].
|
||||
pub fn migrate_color_profiles(&mut self) {
|
||||
let Some(v) = self.unknown.remove(LEGACY_PROFILES_KEY) else { return };
|
||||
if self.color_profiles.is_empty() {
|
||||
self.color_profiles = ColorProfiles::from_legacy(&v);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reverse of [`Document::migrate_color_profiles`], for saving in a version before v3.
|
||||
pub fn store_legacy_color_profiles(&mut self) {
|
||||
let profiles = std::mem::take(&mut self.color_profiles);
|
||||
if !profiles.is_empty() {
|
||||
self.unknown.insert(LEGACY_PROFILES_KEY.into(), profiles.to_legacy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_profiles_migrate_both_ways() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.unknown.insert(LEGACY_PROFILES_KEY.into(), serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
|
||||
d.migrate_color_profiles();
|
||||
assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("Wide RGB".into()), cmyk: None });
|
||||
assert!(!d.unknown.contains_key(LEGACY_PROFILES_KEY));
|
||||
d.store_legacy_color_profiles();
|
||||
assert!(d.color_profiles.is_empty());
|
||||
assert_eq!(d.unknown[LEGACY_PROFILES_KEY], serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
|
||||
// Nothing assigned: nothing stored.
|
||||
let mut e = Document::new(10.0, 10.0);
|
||||
e.store_legacy_color_profiles();
|
||||
assert!(e.unknown.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Puppet Warp pins: editing state the Puppet Warp tool and `object.puppetWarp` share while the
|
||||
//! same artwork stays selected. It lives in the document (never saved) so Undo and Redo take the
|
||||
//! pins back with the warps they made.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Document, Node, NodeId};
|
||||
|
||||
/// One pin: where it sits on the rest shape, where it is now, and the turn it holds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PuppetPin {
|
||||
pub rest: Point,
|
||||
pub at: Point,
|
||||
/// Radians the art is turned around the pin; `None`: free to turn.
|
||||
pub angle: Option<f64>,
|
||||
}
|
||||
|
||||
/// The pins on some objects and the shape they warp from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PuppetPins {
|
||||
/// The warped objects.
|
||||
pub ids: Vec<NodeId>,
|
||||
/// Their rest shape: the objects as they were when pinning began (the warp always starts
|
||||
/// from it, so warps don't stack and dragging a pin back restores the original).
|
||||
pub rest: Vec<Arc<Node>>,
|
||||
/// The objects as the last warp left them: once anything else edits them, the pins start
|
||||
/// again from the objects as they are.
|
||||
pub result: Vec<Arc<Node>>,
|
||||
pub pins: Vec<PuppetPin>,
|
||||
}
|
||||
|
||||
impl PuppetPins {
|
||||
/// The pins are still those of `ids` in `doc`: the same objects, as the last warp left them.
|
||||
pub fn holds(&self, doc: &Document, ids: &[NodeId]) -> bool {
|
||||
self.ids == ids
|
||||
&& self.ids.len() == self.result.len()
|
||||
&& ids.iter().zip(&self.result).all(|(id, r)| doc.node_arc(*id).is_some_and(|a| Arc::ptr_eq(a, r)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The node `id` as the document holds it (shared: comparing pointers says whether it changed).
|
||||
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
|
||||
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Arc<Node>> {
|
||||
for n in nodes {
|
||||
if n.id == id {
|
||||
return Some(n);
|
||||
}
|
||||
if let Some(f) = n.children().and_then(|ch| find(ch, id)) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
find(&self.layers, id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Appearance;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
#[test]
|
||||
fn pins_hold_until_their_objects_change_and_are_never_saved() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
||||
let node = d.node_arc(id).cloned().unwrap();
|
||||
let pin = PuppetPin { rest: Point::new(5.0, 5.0), at: Point::new(6.0, 5.0), angle: Some(0.5) };
|
||||
d.puppet = Some(Arc::new(PuppetPins { ids: vec![id], rest: vec![node.clone()], result: vec![node], pins: vec![pin] }));
|
||||
let pins = d.puppet.clone().unwrap();
|
||||
assert!(pins.holds(&d, &[id]));
|
||||
assert!(!pins.holds(&d, &[id, l]), "other objects");
|
||||
// Saved and read back without them (editing state).
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(!json.contains("puppet"));
|
||||
let back: Document = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.puppet.is_none());
|
||||
assert_eq!(back.node(id), d.node(id));
|
||||
// An edit by anything else: the pins no longer hold.
|
||||
d.node_mut(id).unwrap().name = Some("moved".into());
|
||||
assert!(!pins.holds(&d, &[id]));
|
||||
}
|
||||
}
|
||||